Merge branch 'main' into feature/screen-management

This commit is contained in:
kjs 2025-10-17 17:15:47 +09:00
commit 52c7391cf5
60 changed files with 5901 additions and 1253 deletions

1028
YARD_MANAGEMENT_3D_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,3 +10,8 @@ BOOKING_DATA_SOURCE=file
MAINTENANCE_DATA_SOURCE=memory
DOCUMENT_DATA_SOURCE=memory
# OpenWeatherMap API 키 추가 (실시간 날씨)
# https://openweathermap.org/api 에서 무료 가입 후 발급
OPENWEATHER_API_KEY=your_openweathermap_api_key_here

View File

@ -55,7 +55,8 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import warehouseRoutes from "./routes/warehouseRoutes"; // 창고 관리
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -205,7 +206,8 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
app.use("/api/todos", todoRoutes); // To-Do 관리
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
app.use("/api/warehouse", warehouseRoutes); // 창고 관리
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
app.use("/api/materials", materialRoutes); // 자재 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
@ -235,7 +237,7 @@ app.listen(PORT, HOST, async () => {
// 대시보드 마이그레이션 실행
try {
const { runDashboardMigration } = await import('./database/runMigration');
const { runDashboardMigration } = await import("./database/runMigration");
await runDashboardMigration();
} catch (error) {
logger.error(`❌ 대시보드 마이그레이션 실패:`, error);

View File

@ -0,0 +1,68 @@
import { Request, Response } from "express";
import MaterialService from "../services/MaterialService";
export class MaterialController {
// 임시 자재 마스터 목록 조회
async getTempMaterials(req: Request, res: Response) {
try {
const { search, category, page, limit } = req.query;
const result = await MaterialService.getTempMaterials({
search: search as string,
category: category as string,
page: page ? parseInt(page as string) : 1,
limit: limit ? parseInt(limit as string) : 20,
});
return res.json({ success: true, ...result });
} catch (error: any) {
console.error("Error fetching temp materials:", error);
return res.status(500).json({
success: false,
message: "자재 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 특정 자재 상세 조회
async getTempMaterialByCode(req: Request, res: Response) {
try {
const { code } = req.params;
const material = await MaterialService.getTempMaterialByCode(code);
if (!material) {
return res.status(404).json({
success: false,
message: "자재를 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: material });
} catch (error: any) {
console.error("Error fetching temp material:", error);
return res.status(500).json({
success: false,
message: "자재 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 카테고리 목록 조회
async getCategories(req: Request, res: Response) {
try {
const categories = await MaterialService.getCategories();
return res.json({ success: true, data: categories });
} catch (error: any) {
console.error("Error fetching categories:", error);
return res.status(500).json({
success: false,
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
}
export default new MaterialController();

View File

@ -1,97 +0,0 @@
import { Request, Response } from "express";
import { WarehouseService } from "../services/WarehouseService";
export class WarehouseController {
private warehouseService: WarehouseService;
constructor() {
this.warehouseService = new WarehouseService();
}
// 창고 및 자재 데이터 조회
getWarehouseData = async (req: Request, res: Response) => {
try {
const data = await this.warehouseService.getWarehouseData();
return res.json({
success: true,
warehouses: data.warehouses,
materials: data.materials,
});
} catch (error: any) {
console.error("창고 데이터 조회 오류:", error);
return res.status(500).json({
success: false,
message: "창고 데이터를 불러오는데 실패했습니다.",
error: error.message,
});
}
};
// 특정 창고 정보 조회
getWarehouseById = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const warehouse = await this.warehouseService.getWarehouseById(id);
if (!warehouse) {
return res.status(404).json({
success: false,
message: "창고를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: warehouse,
});
} catch (error: any) {
console.error("창고 조회 오류:", error);
return res.status(500).json({
success: false,
message: "창고 정보를 불러오는데 실패했습니다.",
error: error.message,
});
}
};
// 창고별 자재 목록 조회
getMaterialsByWarehouse = async (req: Request, res: Response) => {
try {
const { warehouseId } = req.params;
const materials =
await this.warehouseService.getMaterialsByWarehouse(warehouseId);
return res.json({
success: true,
data: materials,
});
} catch (error: any) {
console.error("자재 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "자재 목록을 불러오는데 실패했습니다.",
error: error.message,
});
}
};
// 창고 통계 조회
getWarehouseStats = async (req: Request, res: Response) => {
try {
const stats = await this.warehouseService.getWarehouseStats();
return res.json({
success: true,
data: stats,
});
} catch (error: any) {
console.error("창고 통계 조회 오류:", error);
return res.status(500).json({
success: false,
message: "창고 통계를 불러오는데 실패했습니다.",
error: error.message,
});
}
};
}

View File

@ -0,0 +1,299 @@
import { Request, Response } from "express";
import YardLayoutService from "../services/YardLayoutService";
export class YardLayoutController {
// 모든 야드 레이아웃 목록 조회
async getAllLayouts(req: Request, res: Response) {
try {
const layouts = await YardLayoutService.getAllLayouts();
res.json({ success: true, data: layouts });
} catch (error: any) {
console.error("Error fetching yard layouts:", error);
res.status(500).json({
success: false,
message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 특정 야드 레이아웃 상세 조회
async getLayoutById(req: Request, res: Response) {
try {
const { id } = req.params;
const layout = await YardLayoutService.getLayoutById(parseInt(id));
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error: any) {
console.error("Error fetching yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 새 야드 레이아웃 생성
async createLayout(req: Request, res: Response) {
try {
const { name, description } = req.body;
if (!name) {
return res.status(400).json({
success: false,
message: "야드 이름은 필수입니다.",
});
}
const created_by = (req as any).user?.userId || "system";
const layout = await YardLayoutService.createLayout({
name,
description,
created_by,
});
return res.status(201).json({ success: true, data: layout });
} catch (error: any) {
console.error("Error creating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 수정
async updateLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const { name, description } = req.body;
const layout = await YardLayoutService.updateLayout(parseInt(id), {
name,
description,
});
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: layout });
} catch (error: any) {
console.error("Error updating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 삭제
async deleteLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const layout = await YardLayoutService.deleteLayout(parseInt(id));
if (!layout) {
return res.status(404).json({
success: false,
message: "야드 레이아웃을 찾을 수 없습니다.",
});
}
return res.json({
success: true,
message: "야드 레이아웃이 삭제되었습니다.",
});
} catch (error: any) {
console.error("Error deleting yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 특정 야드의 모든 배치 자재 조회
async getPlacementsByLayoutId(req: Request, res: Response) {
try {
const { id } = req.params;
const placements = await YardLayoutService.getPlacementsByLayoutId(
parseInt(id)
);
res.json({ success: true, data: placements });
} catch (error: any) {
console.error("Error fetching placements:", error);
res.status(500).json({
success: false,
message: "배치 자재 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드에 자재 배치 추가
async addMaterialPlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placementData = req.body;
if (!placementData.external_material_id || !placementData.material_code) {
return res.status(400).json({
success: false,
message: "자재 정보가 필요합니다.",
});
}
const placement = await YardLayoutService.addMaterialPlacement(
parseInt(id),
placementData
);
return res.status(201).json({ success: true, data: placement });
} catch (error: any) {
console.error("Error adding material placement:", error);
if (error.code === "23505") {
// 유니크 제약 조건 위반
return res.status(409).json({
success: false,
message: "이미 배치된 자재입니다.",
});
}
return res.status(500).json({
success: false,
message: "자재 배치 추가 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 배치 정보 수정
async updatePlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placementData = req.body;
const placement = await YardLayoutService.updatePlacement(
parseInt(id),
placementData
);
if (!placement) {
return res.status(404).json({
success: false,
message: "배치 정보를 찾을 수 없습니다.",
});
}
return res.json({ success: true, data: placement });
} catch (error: any) {
console.error("Error updating placement:", error);
return res.status(500).json({
success: false,
message: "배치 정보 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 배치 해제
async removePlacement(req: Request, res: Response) {
try {
const { id } = req.params;
const placement = await YardLayoutService.removePlacement(parseInt(id));
if (!placement) {
return res.status(404).json({
success: false,
message: "배치 정보를 찾을 수 없습니다.",
});
}
return res.json({ success: true, message: "배치가 해제되었습니다." });
} catch (error: any) {
console.error("Error removing placement:", error);
return res.status(500).json({
success: false,
message: "배치 해제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 여러 배치 일괄 업데이트
async batchUpdatePlacements(req: Request, res: Response) {
try {
const { id } = req.params;
const { placements } = req.body;
if (!Array.isArray(placements) || placements.length === 0) {
return res.status(400).json({
success: false,
message: "배치 목록이 필요합니다.",
});
}
const updatedPlacements = await YardLayoutService.batchUpdatePlacements(
parseInt(id),
placements
);
return res.json({ success: true, data: updatedPlacements });
} catch (error: any) {
console.error("Error batch updating placements:", error);
return res.status(500).json({
success: false,
message: "배치 일괄 업데이트 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// 야드 레이아웃 복제
async duplicateLayout(req: Request, res: Response) {
try {
const { id } = req.params;
const { name } = req.body;
if (!name) {
return res.status(400).json({
success: false,
message: "새 야드 이름은 필수입니다.",
});
}
const layout = await YardLayoutService.duplicateLayout(
parseInt(id),
name
);
return res.status(201).json({ success: true, data: layout });
} catch (error: any) {
console.error("Error duplicating yard layout:", error);
return res.status(500).json({
success: false,
message: "야드 레이아웃 복제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
}
export default new YardLayoutController();

View File

@ -17,19 +17,54 @@ export class OpenApiProxyController {
console.log(`🌤️ 날씨 조회 요청: ${city}`);
// 기상청 API Hub 키 확인
// 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트)
const openWeatherKey = process.env.OPENWEATHER_API_KEY;
if (openWeatherKey) {
try {
console.log(`🌍 OpenWeatherMap API 호출: ${city}`);
const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', {
params: {
q: `${city},KR`,
appid: openWeatherKey,
units: 'metric',
lang: 'kr',
},
timeout: 10000,
});
const data = response.data;
const weatherData = {
city: data.name,
country: data.sys.country,
temperature: Math.round(data.main.temp),
feelsLike: Math.round(data.main.feels_like),
humidity: data.main.humidity,
pressure: data.main.pressure,
weatherMain: data.weather[0].main,
weatherDescription: data.weather[0].description,
weatherIcon: data.weather[0].icon,
windSpeed: Math.round(data.wind.speed * 10) / 10,
clouds: data.clouds.all,
timestamp: new Date().toISOString(),
};
console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`);
res.json({ success: true, data: weatherData });
return;
} catch (error) {
console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error);
}
}
// 2순위: 기상청 API Hub (매시간 정시 데이터)
const apiKey = process.env.KMA_API_KEY;
// API 키가 없으면 테스트 모드로 실시간 날씨 제공
// API 키가 없으면 오류 반환
if (!apiKey) {
console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.');
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.');
res.status(503).json({
success: false,
message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.',
});
return;
}
@ -48,32 +83,39 @@ export class OpenApiProxyController {
// 기상청 API Hub 사용 (apihub.kma.go.kr)
const now = new Date();
// 기상청 데이터는 매시간 정시(XX:00)에 발표되고 약 10분 후 조회 가능
// 현재 시각이 XX:10 이전이면 이전 시간 데이터 조회
const minute = now.getMinutes();
let targetTime = new Date(now);
// 한국 시간(KST = UTC+9)으로 변환
const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로
const kstNow = new Date(now.getTime() + kstOffset);
if (minute < 10) {
// 아직 이번 시간 데이터가 업데이트되지 않음 → 이전 시간으로
targetTime = new Date(now.getTime() - 60 * 60 * 1000);
}
// 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표
// 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정
const targetTime = new Date(kstNow);
// tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회)
const year = targetTime.getFullYear();
const month = String(targetTime.getMonth() + 1).padStart(2, '0');
const day = String(targetTime.getDate()).padStart(2, '0');
const hour = String(targetTime.getHours()).padStart(2, '0');
const year = targetTime.getUTCFullYear();
const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0');
const day = String(targetTime.getUTCDate()).padStart(2, '0');
const hour = String(targetTime.getUTCHours()).padStart(2, '0');
const tm = `${year}${month}${day}${hour}00`;
console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`);
// 기상청 API Hub - 지상관측시간자료
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php';
// 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보)
// sfctm3: 시간 범위 조회 가능 (tm1~tm2)
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php';
// 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준
const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전
const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`;
const tm2 = tm; // 현재 시간
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 시간: ${tm})`);
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`);
const response = await axios.get(url, {
params: {
tm: tm,
stn: 0, // 0 = 전체 관측소 데이터 조회
tm1: tm1,
tm2: tm2,
stn: regionCode.stnId, // 특정 관측소만 조회
authKey: apiKey,
help: 0,
disp: 1,
@ -95,30 +137,36 @@ export class OpenApiProxyController {
} catch (error: unknown) {
console.error('❌ 날씨 조회 실패:', error);
// API 호출 실패 시 자동으로 테스트 모드로 전
// API 호출 실패 시 명확한 오류 메시지 반
if (axios.isAxiosError(error)) {
const status = error.response?.status;
// 모든 오류 → 테스트 데이터 반환
console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.');
const { city = '서울' } = req.query;
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
});
if (status === 401 || status === 403) {
res.status(401).json({
success: false,
message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.',
});
} else if (status === 404) {
res.status(404).json({
success: false,
message: '기상청 API에서 데이터를 찾을 수 없습니다.',
});
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
res.status(504).json({
success: false,
message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
});
} else {
res.status(500).json({
success: false,
message: '기상청 API 호출 중 오류가 발생했습니다.',
error: error.message,
});
}
} else {
// 예상치 못한 오류 → 테스트 데이터 반환
console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.');
const { city = '서울' } = req.query;
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
res.status(500).json({
success: false,
message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
});
}
}
@ -169,15 +217,19 @@ export class OpenApiProxyController {
} catch (error: unknown) {
console.error('❌ 환율 조회 실패:', error);
// API 호출 실패 시 실제 근사값 반환
console.log('⚠️ API 오류 발생. 근사값을 반환합니다.');
const { base = 'KRW', target = 'USD' } = req.query;
const approximateRate = generateRealisticExchangeRate(base as string, target as string);
res.json({
success: true,
data: approximateRate,
});
// API 호출 실패 시 명확한 오류 메시지 반환
if (axios.isAxiosError(error)) {
res.status(500).json({
success: false,
message: '환율 정보를 가져오는 중 오류가 발생했습니다.',
error: error.message,
});
} else {
res.status(500).json({
success: false,
message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
});
}
}
}
@ -605,19 +657,26 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st
throw new Error('날씨 데이터를 파싱할 수 없습니다.');
}
// 요청한 관측소(stnId)의 데이터 찾기
const targetLine = lines.find((line: string) => {
// 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨)
const targetLines = lines.filter((line: string) => {
const cols = line.trim().split(/\s+/);
return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1)
});
if (!targetLine) {
if (targetLines.length === 0) {
throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`);
}
// 가장 최근 데이터 선택 (마지막 줄)
const targetLine = targetLines[targetLines.length - 1];
// 데이터 라인 파싱 (공백으로 구분)
const values = targetLine.trim().split(/\s+/);
// 관측 시각 로깅
const obsTime = values[0]; // YYMMDDHHMI
console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`);
// 기상청 API Hub 데이터 형식 (실제 응답 기준):
// [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ...
const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11)

View File

@ -0,0 +1,15 @@
import express from "express";
import MaterialController from "../controllers/MaterialController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 임시 자재 마스터 관리
router.get("/temp", MaterialController.getTempMaterials);
router.get("/temp/categories", MaterialController.getCategories);
router.get("/temp/:code", MaterialController.getTempMaterialByCode);
export default router;

View File

@ -0,0 +1,52 @@
import express from "express";
import { query } from "../database/db";
const router = express.Router();
/**
* API
* - active/warning
*/
router.post("/move", async (req, res) => {
try {
// move_vehicles() 함수 실행
await query("SELECT move_vehicles()");
res.json({
success: true,
message: "차량 위치가 업데이트되었습니다"
});
} catch (error) {
console.error("차량 위치 업데이트 오류:", error);
res.status(500).json({
success: false,
error: "차량 위치 업데이트 실패"
});
}
});
/**
*
*/
router.get("/locations", async (req, res) => {
try {
const result = await query(`
SELECT * FROM vehicle_locations
ORDER BY last_update DESC
`);
res.json({
success: true,
data: result
});
} catch (error) {
console.error("차량 위치 조회 오류:", error);
res.status(500).json({
success: false,
error: "차량 위치 조회 실패"
});
}
});
export default router;

View File

@ -1,22 +0,0 @@
import { Router } from "express";
import { WarehouseController } from "../controllers/WarehouseController";
const router = Router();
const warehouseController = new WarehouseController();
// 창고 및 자재 데이터 조회
router.get("/data", warehouseController.getWarehouseData);
// 특정 창고 정보 조회
router.get("/:id", warehouseController.getWarehouseById);
// 창고별 자재 목록 조회
router.get(
"/:warehouseId/materials",
warehouseController.getMaterialsByWarehouse
);
// 창고 통계 조회
router.get("/stats/summary", warehouseController.getWarehouseStats);
export default router;

View File

@ -0,0 +1,27 @@
import express from "express";
import YardLayoutController from "../controllers/YardLayoutController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 야드 레이아웃 관리
router.get("/", YardLayoutController.getAllLayouts);
router.get("/:id", YardLayoutController.getLayoutById);
router.post("/", YardLayoutController.createLayout);
router.put("/:id", YardLayoutController.updateLayout);
router.delete("/:id", YardLayoutController.deleteLayout);
router.post("/:id/duplicate", YardLayoutController.duplicateLayout);
// 자재 배치 관리
router.get("/:id/placements", YardLayoutController.getPlacementsByLayoutId);
router.post("/:id/placements", YardLayoutController.addMaterialPlacement);
router.put("/:id/placements/batch", YardLayoutController.batchUpdatePlacements);
// 개별 배치 관리 (별도 경로)
router.put("/placements/:id", YardLayoutController.updatePlacement);
router.delete("/placements/:id", YardLayoutController.removePlacement);
export default router;

View File

@ -61,8 +61,9 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config,
list_config, yard_config,
display_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
`,
[
elementId,
@ -79,6 +80,8 @@ export class DashboardService {
element.content || null,
JSON.stringify(element.dataSource || {}),
JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null),
i,
now,
now,
@ -342,6 +345,16 @@ export class DashboardService {
content: row.content,
dataSource: JSON.parse(row.data_source_config || "{}"),
chartConfig: JSON.parse(row.chart_config || "{}"),
listConfig: row.list_config
? typeof row.list_config === "string"
? JSON.parse(row.list_config)
: row.list_config
: undefined,
yardConfig: row.yard_config
? typeof row.yard_config === "string"
? JSON.parse(row.yard_config)
: row.yard_config
: undefined,
})
);
@ -465,8 +478,9 @@ export class DashboardService {
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, custom_title, show_header, content, data_source_config, chart_config,
list_config, yard_config,
display_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
`,
[
elementId,
@ -483,6 +497,8 @@ export class DashboardService {
element.content || null,
JSON.stringify(element.dataSource || {}),
JSON.stringify(element.chartConfig || {}),
JSON.stringify(element.listConfig || null),
JSON.stringify(element.yardConfig || null),
i,
now,
now,

View File

@ -0,0 +1,111 @@
import { getPool } from "../database/db";
export class MaterialService {
// 임시 자재 마스터 목록 조회
async getTempMaterials(params: {
search?: string;
category?: string;
page?: number;
limit?: number;
}) {
const { search, category, page = 1, limit = 20 } = params;
const offset = (page - 1) * limit;
let whereConditions: string[] = ["is_active = true"];
const queryParams: any[] = [];
let paramIndex = 1;
if (search) {
whereConditions.push(
`(material_code ILIKE $${paramIndex} OR material_name ILIKE $${paramIndex})`
);
queryParams.push(`%${search}%`);
paramIndex++;
}
if (category) {
whereConditions.push(`category = $${paramIndex}`);
queryParams.push(category);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const pool = getPool();
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as total FROM temp_material_master ${whereClause}`;
const countResult = await pool.query(countQuery, queryParams);
const total = parseInt(countResult.rows[0].total);
// 데이터 조회
const dataQuery = `
SELECT
id,
material_code,
material_name,
category,
unit,
default_color,
description,
created_at
FROM temp_material_master
${whereClause}
ORDER BY material_code ASC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
queryParams.push(limit, offset);
const dataResult = await pool.query(dataQuery, queryParams);
return {
data: dataResult.rows,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
// 특정 자재 상세 조회
async getTempMaterialByCode(materialCode: string) {
const query = `
SELECT
id,
material_code,
material_name,
category,
unit,
default_color,
description,
created_at
FROM temp_material_master
WHERE material_code = $1 AND is_active = true
`;
const pool = getPool();
const result = await pool.query(query, [materialCode]);
return result.rows[0] || null;
}
// 카테고리 목록 조회
async getCategories() {
const query = `
SELECT DISTINCT category
FROM temp_material_master
WHERE is_active = true AND category IS NOT NULL
ORDER BY category ASC
`;
const pool = getPool();
const result = await pool.query(query);
return result.rows.map((row) => row.category);
}
}
export default new MaterialService();

View File

@ -1,170 +0,0 @@
import pool from "../database/db";
export class WarehouseService {
// 창고 및 자재 데이터 조회
async getWarehouseData() {
try {
// 창고 목록 조회
const warehousesResult = await pool.query(`
SELECT
id,
name,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
capacity,
current_usage,
status,
description,
created_at,
updated_at
FROM warehouse
WHERE status = 'active'
ORDER BY id
`);
// 자재 목록 조회
const materialsResult = await pool.query(`
SELECT
id,
warehouse_id,
name,
material_code,
quantity,
unit,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
status,
last_updated,
created_at
FROM warehouse_material
ORDER BY warehouse_id, id
`);
return {
warehouses: warehousesResult,
materials: materialsResult,
};
} catch (error) {
throw error;
}
}
// 특정 창고 정보 조회
async getWarehouseById(id: string) {
try {
const result = await pool.query(
`
SELECT
id,
name,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
capacity,
current_usage,
status,
description,
created_at,
updated_at
FROM warehouse
WHERE id = $1
`,
[id]
);
return result[0] || null;
} catch (error) {
throw error;
}
}
// 창고별 자재 목록 조회
async getMaterialsByWarehouse(warehouseId: string) {
try {
const result = await pool.query(
`
SELECT
id,
warehouse_id,
name,
material_code,
quantity,
unit,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
status,
last_updated,
created_at
FROM warehouse_material
WHERE warehouse_id = $1
ORDER BY id
`,
[warehouseId]
);
return result;
} catch (error) {
throw error;
}
}
// 창고 통계 조회
async getWarehouseStats() {
try {
const result = await pool.query(`
SELECT
COUNT(DISTINCT w.id) as total_warehouses,
COUNT(m.id) as total_materials,
SUM(w.capacity) as total_capacity,
SUM(w.current_usage) as total_usage,
ROUND(AVG((w.current_usage::numeric / NULLIF(w.capacity, 0)) * 100), 2) as avg_usage_percent
FROM warehouse w
LEFT JOIN warehouse_material m ON w.id = m.warehouse_id
WHERE w.status = 'active'
`);
// 상태별 자재 수
const statusResult = await pool.query(`
SELECT
status,
COUNT(*) as count
FROM warehouse_material
GROUP BY status
`);
const statusCounts = statusResult.reduce(
(acc: Record<string, number>, row: any) => {
acc[row.status] = parseInt(row.count);
return acc;
},
{} as Record<string, number>
);
return {
...result[0],
materialsByStatus: statusCounts,
};
} catch (error) {
throw error;
}
}
}

View File

@ -0,0 +1,337 @@
import { getPool } from "../database/db";
export class YardLayoutService {
// 모든 야드 레이아웃 목록 조회
async getAllLayouts() {
const query = `
SELECT
yl.id,
yl.name,
yl.description,
yl.created_by,
yl.created_at,
yl.updated_at,
COUNT(ymp.id) as placement_count
FROM yard_layout yl
LEFT JOIN yard_material_placement ymp ON yl.id = ymp.yard_layout_id
GROUP BY yl.id
ORDER BY yl.updated_at DESC
`;
const pool = getPool();
const result = await pool.query(query);
return result.rows;
}
// 특정 야드 레이아웃 상세 조회
async getLayoutById(id: number) {
const query = `
SELECT
id,
name,
description,
created_by,
created_at,
updated_at
FROM yard_layout
WHERE id = $1
`;
const pool = getPool();
const result = await pool.query(query, [id]);
return result.rows[0] || null;
}
// 새 야드 레이아웃 생성
async createLayout(data: {
name: string;
description?: string;
created_by?: string;
}) {
const query = `
INSERT INTO yard_layout (name, description, created_by)
VALUES ($1, $2, $3)
RETURNING *
`;
const pool = getPool();
const result = await pool.query(query, [
data.name,
data.description || null,
data.created_by || null,
]);
return result.rows[0];
}
// 야드 레이아웃 수정 (이름, 설명만)
async updateLayout(
id: number,
data: { name?: string; description?: string }
) {
const query = `
UPDATE yard_layout
SET
name = COALESCE($1, name),
description = COALESCE($2, description),
updated_at = CURRENT_TIMESTAMP
WHERE id = $3
RETURNING *
`;
const pool = getPool();
const result = await pool.query(query, [
data.name || null,
data.description || null,
id,
]);
return result.rows[0] || null;
}
// 야드 레이아웃 삭제
async deleteLayout(id: number) {
const query = `DELETE FROM yard_layout WHERE id = $1 RETURNING *`;
const pool = getPool();
const result = await pool.query(query, [id]);
return result.rows[0] || null;
}
// 특정 야드의 모든 배치 자재 조회
async getPlacementsByLayoutId(layoutId: number) {
const query = `
SELECT
id,
yard_layout_id,
external_material_id,
material_code,
material_name,
quantity,
unit,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
memo,
created_at,
updated_at
FROM yard_material_placement
WHERE yard_layout_id = $1
ORDER BY created_at ASC
`;
const pool = getPool();
const result = await pool.query(query, [layoutId]);
return result.rows;
}
// 야드에 자재 배치 추가
async addMaterialPlacement(layoutId: number, data: any) {
const query = `
INSERT INTO yard_material_placement (
yard_layout_id,
external_material_id,
material_code,
material_name,
quantity,
unit,
position_x,
position_y,
position_z,
size_x,
size_y,
size_z,
color,
memo
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *
`;
const pool = getPool();
const result = await pool.query(query, [
layoutId,
data.external_material_id,
data.material_code,
data.material_name,
data.quantity,
data.unit,
data.position_x || 0,
data.position_y || 0,
data.position_z || 0,
data.size_x || 5,
data.size_y || 5,
data.size_z || 5,
data.color || "#3b82f6",
data.memo || null,
]);
return result.rows[0];
}
// 배치 정보 수정 (위치, 크기, 색상, 메모만)
async updatePlacement(placementId: number, data: any) {
const query = `
UPDATE yard_material_placement
SET
position_x = COALESCE($1, position_x),
position_y = COALESCE($2, position_y),
position_z = COALESCE($3, position_z),
size_x = COALESCE($4, size_x),
size_y = COALESCE($5, size_y),
size_z = COALESCE($6, size_z),
color = COALESCE($7, color),
memo = COALESCE($8, memo),
updated_at = CURRENT_TIMESTAMP
WHERE id = $9
RETURNING *
`;
const pool = getPool();
const result = await pool.query(query, [
data.position_x,
data.position_y,
data.position_z,
data.size_x,
data.size_y,
data.size_z,
data.color,
data.memo,
placementId,
]);
return result.rows[0] || null;
}
// 배치 해제 (자재는 삭제되지 않음)
async removePlacement(placementId: number) {
const query = `DELETE FROM yard_material_placement WHERE id = $1 RETURNING *`;
const pool = getPool();
const result = await pool.query(query, [placementId]);
return result.rows[0] || null;
}
// 여러 배치 일괄 업데이트
async batchUpdatePlacements(layoutId: number, placements: any[]) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const results = [];
for (const placement of placements) {
const query = `
UPDATE yard_material_placement
SET
position_x = $1,
position_y = $2,
position_z = $3,
size_x = $4,
size_y = $5,
size_z = $6,
updated_at = CURRENT_TIMESTAMP
WHERE id = $7 AND yard_layout_id = $8
RETURNING *
`;
const result = await client.query(query, [
placement.position_x,
placement.position_y,
placement.position_z,
placement.size_x,
placement.size_y,
placement.size_z,
placement.id,
layoutId,
]);
if (result.rows[0]) {
results.push(result.rows[0]);
}
}
await client.query("COMMIT");
return results;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
// 야드 레이아웃 복제
async duplicateLayout(id: number, newName: string) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 원본 레이아웃 조회
const layoutQuery = `SELECT * FROM yard_layout WHERE id = $1`;
const layoutResult = await client.query(layoutQuery, [id]);
const originalLayout = layoutResult.rows[0];
if (!originalLayout) {
throw new Error("Layout not found");
}
// 새 레이아웃 생성
const newLayoutQuery = `
INSERT INTO yard_layout (name, description, created_by)
VALUES ($1, $2, $3)
RETURNING *
`;
const newLayoutResult = await client.query(newLayoutQuery, [
newName,
originalLayout.description,
originalLayout.created_by,
]);
const newLayout = newLayoutResult.rows[0];
// 배치 자재 복사
const placementsQuery = `SELECT * FROM yard_material_placement WHERE yard_layout_id = $1`;
const placementsResult = await client.query(placementsQuery, [id]);
for (const placement of placementsResult.rows) {
await client.query(
`
INSERT INTO yard_material_placement (
yard_layout_id, external_material_id, material_code, material_name,
quantity, unit, position_x, position_y, position_z,
size_x, size_y, size_z, color, memo
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`,
[
newLayout.id,
placement.external_material_id,
placement.material_code,
placement.material_name,
placement.quantity,
placement.unit,
placement.position_x,
placement.position_y,
placement.position_z,
placement.size_x,
placement.size_y,
placement.size_z,
placement.color,
placement.memo,
]
);
}
await client.query("COMMIT");
return newLayout;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
}
export default new YardLayoutService();

View File

@ -25,8 +25,8 @@ export class RiskAlertService {
const apiKey = process.env.KMA_API_KEY;
if (!apiKey) {
console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.');
return this.generateDummyWeatherAlerts();
console.log('⚠️ 기상청 API 키가 없습니다. 데이터를 반환합니다.');
return [];
}
const alerts: Alert[] = [];
@ -109,7 +109,7 @@ export class RiskAlertService {
console.log(`✅ 총 ${alerts.length}건의 기상특보 감지`);
} catch (warningError: any) {
console.error('❌ 기상청 특보 API 오류:', warningError.message);
return this.generateDummyWeatherAlerts();
return [];
}
// 특보가 없으면 빈 배열 반환 (0건)
@ -120,8 +120,8 @@ export class RiskAlertService {
return alerts;
} catch (error: any) {
console.error('❌ 기상청 특보 API 오류:', error.message);
// API 오류 시 더미 데이터 반환
return this.generateDummyWeatherAlerts();
// API 오류 시 빈 배열 반환
return [];
}
}
@ -237,9 +237,9 @@ export class RiskAlertService {
console.error('❌ 한국도로공사 API 오류:', error.message);
}
// 모든 API 실패 시 더미 데이터
console.log(' 모든 교통사고 API 실패. 더미 데이터를 반환합니다.');
return this.generateDummyAccidentAlerts();
// 모든 API 실패 시 빈 배열
console.log(' 모든 교통사고 API 실패. 빈 배열을 반환합니다.');
return [];
}
/**
@ -356,9 +356,9 @@ export class RiskAlertService {
console.error('❌ 한국도로공사 API 오류:', error.message);
}
// 모든 API 실패 시 더미 데이터
console.log(' 모든 도로공사 API 실패. 더미 데이터를 반환합니다.');
return this.generateDummyRoadworkAlerts();
// 모든 API 실패 시 빈 배열
console.log(' 모든 도로공사 API 실패. 빈 배열을 반환합니다.');
return [];
}
/**
@ -467,82 +467,5 @@ export class RiskAlertService {
return 'low';
}
/**
*
*/
private generateDummyWeatherAlerts(): Alert[] {
return [
{
id: `weather-${Date.now()}-1`,
type: 'weather',
severity: 'high',
title: '대설특보',
location: '강원 영동지역',
description: '시간당 2cm 이상 폭설. 차량 운행 주의',
timestamp: new Date(Date.now() - 30 * 60000).toISOString(),
},
{
id: `weather-${Date.now()}-2`,
type: 'weather',
severity: 'medium',
title: '강풍특보',
location: '남해안 전 지역',
description: '순간 풍속 20m/s 이상. 고속도로 주행 주의',
timestamp: new Date(Date.now() - 90 * 60000).toISOString(),
},
];
}
/**
*
*/
private generateDummyAccidentAlerts(): Alert[] {
return [
{
id: `accident-${Date.now()}-1`,
type: 'accident',
severity: 'high',
title: '교통사고 발생',
location: '경부고속도로 서울방향 189km',
description: '3중 추돌사고로 2차로 통제 중. 우회 권장',
timestamp: new Date(Date.now() - 10 * 60000).toISOString(),
},
{
id: `accident-${Date.now()}-2`,
type: 'accident',
severity: 'medium',
title: '사고 다발 지역',
location: '영동고속도로 강릉방향 160km',
description: '안개로 인한 가시거리 50m 이하. 서행 운전',
timestamp: new Date(Date.now() - 60 * 60000).toISOString(),
},
];
}
/**
*
*/
private generateDummyRoadworkAlerts(): Alert[] {
return [
{
id: `construction-${Date.now()}-1`,
type: 'construction',
severity: 'medium',
title: '도로 공사',
location: '서울외곽순환 목동IC~화곡IC',
description: '야간 공사로 1차로 통제 (22:00~06:00)',
timestamp: new Date(Date.now() - 45 * 60000).toISOString(),
},
{
id: `construction-${Date.now()}-2`,
type: 'construction',
severity: 'low',
title: '도로 통제',
location: '중부내륙고속도로 김천JC~현풍IC',
description: '도로 유지보수 작업. 차량 속도 제한 60km/h',
timestamp: new Date(Date.now() - 120 * 60000).toISOString(),
},
];
}
}

View File

@ -35,6 +35,16 @@ export interface DashboardElement {
title?: string;
showLegend?: boolean;
};
listConfig?: {
columns?: any[];
pagination?: any;
viewMode?: string;
cardColumns?: number;
};
yardConfig?: {
layoutId: number;
layoutName?: string;
};
}
export interface Dashboard {

View File

@ -25,12 +25,12 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
title: string;
description?: string;
elements: DashboardElement[];
settings?: {
backgroundColor?: string;
resolution?: string;
};
createdAt: string;
updatedAt: string;
settings?: {
resolution?: string;
backgroundColor?: string;
};
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -105,8 +105,65 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
return (
<div className="h-screen bg-gray-50">
{/* 대시보드 뷰어 - 전체 화면 */}
<DashboardViewer elements={dashboard.elements} resolution={dashboard.settings?.resolution || "fhd"} />
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
{/* <div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-800">{dashboard.title}</h1>
{dashboard.description && <p className="mt-1 text-sm text-gray-600">{dashboard.description}</p>}
</div>
<div className="flex items-center gap-3">
{/* *\/}
<button
onClick={loadDashboard}
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
title="새로고침"
>
🔄
</button>
{/* *\/}
<button
onClick={() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
}}
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
title="전체화면"
>
</button>
{/* *\/}
<button
onClick={() => {
router.push(`/admin/dashboard?load=${resolvedParams.dashboardId}`);
}}
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
</button>
</div>
</div>
{/* *\/}
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
<span>: {new Date(dashboard.createdAt).toLocaleString()}</span>
<span>: {new Date(dashboard.updatedAt).toLocaleString()}</span>
<span>: {dashboard.elements.length}</span>
</div>
</div> */}
{/* 대시보드 뷰어 */}
<DashboardViewer
elements={dashboard.elements}
dashboardId={dashboard.id}
backgroundColor={dashboard.settings?.backgroundColor}
/>
</div>
);
}

View File

@ -106,6 +106,12 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
import { ListWidget } from "./widgets/ListWidget";
// 야드 관리 3D 위젯
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
@ -208,7 +214,7 @@ export function CanvasElement({
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
// 임시 위치 계산 (스냅 안 됨)
// 임시 위치 계산
let rawX = Math.max(0, dragStart.elementX + deltaX);
const rawY = Math.max(0, dragStart.elementY + deltaY);
@ -216,7 +222,22 @@ export function CanvasElement({
const maxX = canvasWidth - element.size.width;
rawX = Math.min(rawX, maxX);
setTempPosition({ x: rawX, y: rawY });
// 드래그 중 실시간 스냅 (마그네틱 스냅)
const subGridSize = Math.floor(cellSize / 3);
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px)
// X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
const distToGridX = Math.abs(rawX - nearestGridX);
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
// Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
const distToGridY = Math.abs(rawY - nearestGridY);
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
setTempPosition({ x: snappedX, y: snappedY });
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
@ -259,46 +280,89 @@ export function CanvasElement({
const maxWidth = canvasWidth - newX;
newWidth = Math.min(newWidth, maxWidth);
// 임시 크기/위치 저장 (스냅 안 됨)
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
setTempSize({ width: newWidth, height: newHeight });
// 리사이즈 중 실시간 스냅 (마그네틱 스냅)
const subGridSize = Math.floor(cellSize / 3);
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
const magneticThreshold = 15;
// 위치 스냅
const nearestGridX = Math.round(newX / gridSize) * gridSize;
const distToGridX = Math.abs(newX - nearestGridX);
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(newX / subGridSize) * subGridSize;
const nearestGridY = Math.round(newY / gridSize) * gridSize;
const distToGridY = Math.abs(newY - nearestGridY);
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(newY / subGridSize) * subGridSize;
// 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외)
// 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2
const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5;
// 가장 가까운 그리드 칸 수 계산
const nearestWidthCells = Math.round(newWidth / gridSize);
const nearestGridWidth = calculateGridWidth(nearestWidthCells);
const distToGridWidth = Math.abs(newWidth - nearestGridWidth);
const snappedWidth =
distToGridWidth <= magneticThreshold ? nearestGridWidth : Math.round(newWidth / subGridSize) * subGridSize;
const nearestHeightCells = Math.round(newHeight / gridSize);
const nearestGridHeight = calculateGridWidth(nearestHeightCells);
const distToGridHeight = Math.abs(newHeight - nearestGridHeight);
const snappedHeight =
distToGridHeight <= magneticThreshold ? nearestGridHeight : Math.round(newHeight / subGridSize) * subGridSize;
// 임시 크기/위치 저장 (스냅됨)
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
setTempSize({ width: snappedWidth, height: snappedHeight });
}
},
[isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth],
[
isDragging,
isResizing,
dragStart,
resizeStart,
element.size.width,
element.type,
element.subtype,
canvasWidth,
cellSize,
],
);
// 마우스 업 처리 (그리드 스냅 적용)
// 마우스 업 처리 (이미 스냅된 위치 사용)
const handleMouseUp = useCallback(() => {
if (isDragging && tempPosition) {
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
let snappedX = snapToGrid(tempPosition.x, cellSize);
const snappedY = snapToGrid(tempPosition.y, cellSize);
// tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨
// 다시 스냅하지 않고 그대로 사용!
let finalX = tempPosition.x;
const finalY = tempPosition.y;
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
const maxX = canvasWidth - element.size.width;
snappedX = Math.min(snappedX, maxX);
finalX = Math.min(finalX, maxX);
onUpdate(element.id, {
position: { x: snappedX, y: snappedY },
position: { x: finalX, y: finalY },
});
setTempPosition(null);
}
if (isResizing && tempPosition && tempSize) {
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
const snappedX = snapToGrid(tempPosition.x, cellSize);
const snappedY = snapToGrid(tempPosition.y, cellSize);
let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
// tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨
// 다시 스냅하지 않고 그대로 사용!
const finalX = tempPosition.x;
const finalY = tempPosition.y;
let finalWidth = tempSize.width;
const finalHeight = tempSize.height;
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
const maxWidth = canvasWidth - snappedX;
snappedWidth = Math.min(snappedWidth, maxWidth);
const maxWidth = canvasWidth - finalX;
finalWidth = Math.min(finalWidth, maxWidth);
onUpdate(element.id, {
position: { x: snappedX, y: snappedY },
size: { width: snappedWidth, height: snappedHeight },
position: { x: finalX, y: finalY },
size: { width: finalWidth, height: finalHeight },
});
setTempPosition(null);
@ -368,7 +432,7 @@ export function CanvasElement({
});
}
} catch (error) {
console.error("Chart data loading error:", error);
// console.error("Chart data loading error:", error);
setChartData(null);
} finally {
setIsLoadingData(false);
@ -457,12 +521,9 @@ export function CanvasElement({
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
{onConfigure &&
!(
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) && (
!(element.type === "widget" && element.subtype === "driver-management") && (
<button
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={() => onConfigure(element)}
@ -649,10 +710,21 @@ export function CanvasElement({
}}
/>
</div>
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 야드 관리 3D 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<YardManagement3DWidget
isEditMode={true}
config={element.yardConfig}
onConfigChange={(newConfig) => {
onUpdate(element.id, { yardConfig: newConfig });
}}
/>
</div>
) : element.type === "widget" && element.subtype === "todo" ? (
// To-Do 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<TodoWidget />
<TodoWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "booking-alert" ? (
// 예약 요청 알림 위젯 렌더링

View File

@ -98,7 +98,7 @@ export function ChartConfigPanel({
setDateColumns(schema.dateColumns);
})
.catch((error) => {
console.error("❌ 테이블 스키마 조회 실패:", error);
// console.error("❌ 테이블 스키마 조회 실패:", error);
// 실패 시 빈 배열 (날짜 필터 비활성화)
setDateColumns([]);
});

View File

@ -4,6 +4,7 @@ import React, { forwardRef, useState, useCallback, useMemo } from "react";
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
import { CanvasElement } from "./CanvasElement";
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
import { resolveAllCollisions } from "./collisionUtils";
interface DashboardCanvasProps {
elements: DashboardElement[];
@ -47,6 +48,81 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
const cellSize = gridConfig.CELL_SIZE;
// 충돌 방지 기능이 포함된 업데이트 핸들러
const handleUpdateWithCollisionDetection = useCallback(
(id: string, updates: Partial<DashboardElement>) => {
// position이나 size가 아닌 다른 속성 업데이트는 충돌 감지 없이 바로 처리
if (!updates.position && !updates.size) {
onUpdateElement(id, updates);
return;
}
// 업데이트할 요소 찾기
const elementIndex = elements.findIndex((el) => el.id === id);
if (elementIndex === -1) {
onUpdateElement(id, updates);
return;
}
// position이나 size와 다른 속성이 함께 있으면 분리해서 처리
const positionSizeUpdates: any = {};
const otherUpdates: any = {};
Object.keys(updates).forEach((key) => {
if (key === "position" || key === "size") {
positionSizeUpdates[key] = (updates as any)[key];
} else {
otherUpdates[key] = (updates as any)[key];
}
});
// 다른 속성들은 먼저 바로 업데이트
if (Object.keys(otherUpdates).length > 0) {
onUpdateElement(id, otherUpdates);
}
// position/size가 없으면 여기서 종료
if (Object.keys(positionSizeUpdates).length === 0) {
return;
}
// 임시로 업데이트된 요소 배열 생성
const updatedElements = elements.map((el) =>
el.id === id
? {
...el,
...positionSizeUpdates,
position: positionSizeUpdates.position || el.position,
size: positionSizeUpdates.size || el.size,
}
: el,
);
// 서브 그리드 크기 계산 (cellSize / 3)
const subGridSize = Math.floor(cellSize / 3);
// 충돌 해결 (서브 그리드 단위로 스냅 및 충돌 감지)
const resolvedElements = resolveAllCollisions(updatedElements, id, subGridSize, canvasWidth, cellSize);
// 변경된 요소들만 업데이트
resolvedElements.forEach((resolvedEl, idx) => {
const originalEl = elements[idx];
if (
resolvedEl.position.x !== originalEl.position.x ||
resolvedEl.position.y !== originalEl.position.y ||
resolvedEl.size.width !== originalEl.size.width ||
resolvedEl.size.height !== originalEl.size.height
) {
onUpdateElement(resolvedEl.id, {
position: resolvedEl.position,
size: resolvedEl.size,
});
}
});
},
[elements, onUpdateElement, cellSize, canvasWidth],
);
// 드래그 오버 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@ -79,9 +155,21 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
// 그리드에 스냅 (동적 셀 크기 사용)
let snappedX = snapToGrid(rawX, cellSize);
const snappedY = snapToGrid(rawY, cellSize);
// 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드)
const subGridSize = Math.floor(cellSize / 3);
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
const magneticThreshold = 15;
// X 좌표 스냅
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
const distToGridX = Math.abs(rawX - nearestGridX);
let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
// Y 좌표 스냅
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
const distToGridY = Math.abs(rawY - nearestGridY);
const snappedY =
distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
@ -161,7 +249,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
isSelected={selectedElement === element.id}
cellSize={cellSize}
canvasWidth={canvasWidth}
onUpdate={onUpdateElement}
onUpdate={handleUpdateWithCollisionDetection}
onRemove={onRemoveElement}
onSelect={onSelectElement}
onConfigure={onConfigureElement}

View File

@ -10,6 +10,7 @@ import { DashboardSaveModal } from "./DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
@ -183,7 +184,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
// 좌표 유효성 검사
if (isNaN(x) || isNaN(y)) {
console.error("Invalid coordinates:", { x, y });
// console.error("Invalid coordinates:", { x, y });
return;
}
@ -205,14 +206,14 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 크기 유효성 검사
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
console.error("Invalid size calculated:", {
canvasConfig,
cellSize,
cellWithGap,
defaultCells,
defaultWidth,
defaultHeight,
});
// console.error("Invalid size calculated:", {
// canvasConfig,
// cellSize,
// cellWithGap,
// defaultCells,
// defaultWidth,
// defaultHeight,
// });
return;
}
@ -242,7 +243,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 좌표 유효성 확인
if (isNaN(centerX) || isNaN(centerY)) {
console.error("Invalid canvas config:", canvasConfig);
// console.error("Invalid canvas config:", canvasConfig);
return;
}
@ -344,6 +345,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
dataSource: el.dataSource,
chartConfig: el.chartConfig,
listConfig: el.listConfig,
yardConfig: el.yardConfig,
}));
let savedDashboard;
@ -442,125 +444,128 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
}
return (
<div className="flex h-full flex-col bg-gray-50">
{/* 상단 메뉴바 */}
<DashboardTopMenu
onSaveLayout={saveLayout}
onClearCanvas={clearCanvas}
dashboardTitle={dashboardTitle}
onAddElement={addElementFromMenu}
resolution={resolution}
onResolutionChange={handleResolutionChange}
currentScreenResolution={screenResolution}
backgroundColor={canvasBackgroundColor}
onBackgroundColorChange={setCanvasBackgroundColor}
/>
<DashboardProvider>
<div className="flex h-full flex-col bg-gray-50">
{/* 상단 메뉴바 */}
<DashboardTopMenu
onSaveLayout={saveLayout}
onClearCanvas={clearCanvas}
dashboardTitle={dashboardTitle}
onAddElement={addElementFromMenu}
resolution={resolution}
onResolutionChange={handleResolutionChange}
currentScreenResolution={screenResolution}
backgroundColor={canvasBackgroundColor}
onBackgroundColorChange={setCanvasBackgroundColor}
/>
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
<div className="flex flex-1 items-start justify-center overflow-auto bg-gray-100 p-8">
<div
className="relative shadow-2xl"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
<div
className="relative shadow-2xl"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
}}
>
<DashboardCanvas
ref={canvasRef}
elements={elements}
selectedElement={selectedElement}
onCreateElement={createElement}
onUpdateElement={updateElement}
onRemoveElement={removeElement}
onSelectElement={setSelectedElement}
onConfigureElement={openConfigModal}
backgroundColor={canvasBackgroundColor}
canvasWidth={canvasConfig.width}
canvasHeight={dynamicCanvasHeight}
/>
</div>
</div>
{/* 요소 설정 모달 */}
{configModalElement && (
<>
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
<ListWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : (
<ElementConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveElementConfig}
/>
)}
</>
)}
{/* 저장 모달 */}
<DashboardSaveModal
isOpen={saveModalOpen}
onClose={() => setSaveModalOpen(false)}
onSave={handleSave}
initialTitle={dashboardTitle}
initialDescription={dashboardDescription}
isEditing={!!dashboardId}
/>
{/* 저장 성공 모달 */}
<Dialog
open={successModalOpen}
onOpenChange={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
<DashboardCanvas
ref={canvasRef}
elements={elements}
selectedElement={selectedElement}
onCreateElement={createElement}
onUpdateElement={updateElement}
onRemoveElement={removeElement}
onSelectElement={setSelectedElement}
onConfigureElement={openConfigModal}
backgroundColor={canvasBackgroundColor}
canvasWidth={canvasConfig.width}
canvasHeight={dynamicCanvasHeight}
/>
</div>
</div>
{/* 요소 설정 모달 */}
{configModalElement && (
<>
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
<ListWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : (
<ElementConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveElementConfig}
/>
)}
</>
)}
{/* 저장 모달 */}
<DashboardSaveModal
isOpen={saveModalOpen}
onClose={() => setSaveModalOpen(false)}
onSave={handleSave}
initialTitle={dashboardTitle}
initialDescription={dashboardDescription}
isEditing={!!dashboardId}
/>
{/* 저장 성공 모달 */}
<Dialog
open={successModalOpen}
onOpenChange={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<DialogTitle className="text-center"> </DialogTitle>
<DialogDescription className="text-center"> .</DialogDescription>
</DialogHeader>
<div className="flex justify-center pt-4">
<Button
onClick={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
</Button>
</div>
<DialogTitle className="text-center"> </DialogTitle>
<DialogDescription className="text-center"> .</DialogDescription>
</DialogHeader>
<div className="flex justify-center pt-4">
<Button
onClick={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
</Button>
</div>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
{/* 초기화 확인 모달 */}
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
. .
<br />
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleClearConfirm} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* 초기화 확인 모달 */}
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
. .
<br />
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleClearConfirm} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</DashboardProvider>
);
}
@ -569,38 +574,60 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
if (type === "chart") {
switch (subtype) {
case "bar":
return "📊 바 차트";
return "바 차트";
case "horizontal-bar":
return "📊 수평 바 차트";
return "수평 바 차트";
case "stacked-bar":
return "누적 바 차트";
case "pie":
return "🥧 원형 차트";
return "원형 차트";
case "donut":
return "도넛 차트";
case "line":
return "📈 꺾은선 차트";
return "꺾은선 차트";
case "area":
return "영역 차트";
case "combo":
return "콤보 차트";
default:
return "📊 차트";
return "차트";
}
} else if (type === "widget") {
switch (subtype) {
case "exchange":
return "💱 환율 위젯";
return "환율 위젯";
case "weather":
return "☁️ 날씨 위젯";
return "날씨 위젯";
case "clock":
return "시계 위젯";
return "시계 위젯";
case "calculator":
return "🧮 계산기 위젯";
return "계산기 위젯";
case "vehicle-map":
return "🚚 차량 위치 지도";
return "차량 위치 지도";
case "calendar":
return "📅 달력 위젯";
return "달력 위젯";
case "driver-management":
return "🚚 기사 관리 위젯";
return "기사 관리 위젯";
case "list":
return "📋 리스트 위젯";
case "warehouse-3d":
return "🏭 창고 현황 (3D)";
return "리스트 위젯";
case "map-summary":
return "커스텀 지도 카드";
case "status-summary":
return "커스텀 상태 카드";
case "risk-alert":
return "리스크 알림 위젯";
case "todo":
return "할 일 위젯";
case "booking-alert":
return "예약 알림 위젯";
case "maintenance":
return "정비 일정 위젯";
case "document":
return "문서 위젯";
case "yard-management-3d":
return "야드 관리 3D";
default:
return "🔧 위젯";
return "위젯";
}
}
return "요소";
@ -639,8 +666,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
return "driver-management";
case "list":
return "list-widget";
case "warehouse-3d":
return "warehouse-3d";
case "yard-management-3d":
return "yard-3d";
default:
return "위젯 내용이 여기에 표시됩니다";
}

View File

@ -83,7 +83,7 @@ export function DashboardSaveModal({
setAdminMenus(adminMenuList);
setUserMenus(userMenuList);
} catch (error) {
console.error("메뉴 목록 로드 실패:", error);
// console.error("메뉴 목록 로드 실패:", error);
setAdminMenus([]);
setUserMenus([]);
} finally {
@ -157,7 +157,7 @@ export function DashboardSaveModal({
});
onClose();
} catch (error) {
console.error("저장 실패:", error);
// console.error("저장 실패:", error);
} finally {
setLoading(false);
}

View File

@ -47,56 +47,48 @@ export function DashboardSidebar() {
{expandedSections.charts && (
<div className="space-y-2">
<DraggableItem
icon="📊"
title="바 차트"
type="chart"
subtype="bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="수평 바 차트"
type="chart"
subtype="horizontal-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📚"
title="누적 바 차트"
type="chart"
subtype="stacked-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📈"
title="꺾은선 차트"
type="chart"
subtype="line"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📉"
title="영역 차트"
type="chart"
subtype="area"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🥧"
title="원형 차트"
type="chart"
subtype="pie"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🍩"
title="도넛 차트"
type="chart"
subtype="donut"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="콤보 차트"
type="chart"
subtype="combo"
@ -123,64 +115,60 @@ export function DashboardSidebar() {
{expandedSections.widgets && (
<div className="space-y-2">
<DraggableItem
icon="💱"
title="환율 위젯"
type="widget"
subtype="exchange"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="☁️"
title="날씨 위젯"
type="widget"
subtype="weather"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🧮"
title="계산기 위젯"
type="widget"
subtype="calculator"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="⏰"
title="시계 위젯"
type="widget"
subtype="clock"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📍"
title="커스텀 지도 카드"
type="widget"
subtype="map-summary"
onDragStart={handleDragStart}
/>
{/* 주석: 다른 분이 범용 리스트 작업 중 - 충돌 방지를 위해 임시 주석처리 */}
{/* <DraggableItem
icon="📋"
title="커스텀 목록 카드"
type="widget"
subtype="list-summary"
onDragStart={handleDragStart}
/> */}
<DraggableItem
icon="⚠️"
title="리스크/알림 위젯"
type="widget"
subtype="risk-alert"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📅"
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
title="달력 위젯"
type="widget"
subtype="calendar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="커스텀 상태 카드"
type="widget"
subtype="status-summary"
@ -207,14 +195,12 @@ export function DashboardSidebar() {
{expandedSections.operations && (
<div className="space-y-2">
<DraggableItem
icon="✅"
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🔔"
title="예약 요청 알림"
type="widget"
subtype="booking-alert"
@ -222,14 +208,12 @@ export function DashboardSidebar() {
/>
{/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
<DraggableItem
icon="📂"
title="문서 다운로드"
type="widget"
subtype="document"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📋"
title="리스트 위젯"
type="widget"
subtype="list"
@ -243,7 +227,7 @@ export function DashboardSidebar() {
}
interface DraggableItemProps {
icon: string;
icon?: string;
title: string;
type: ElementType;
subtype: ElementSubtype;
@ -254,7 +238,7 @@ interface DraggableItemProps {
/**
*
*/
function DraggableItem({ icon, title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
function DraggableItem({ title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
return (
<div
draggable

View File

@ -181,8 +181,11 @@ export function DashboardTopMenu({
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem>
<SelectItem value="map"></SelectItem>
<SelectItem value="warehouse-3d"> (3D)</SelectItem>
<SelectItem value="yard-management-3d"> 3D</SelectItem>
{/* <SelectItem value="map">지도</SelectItem> */}
<SelectItem value="map-summary"> </SelectItem>
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}
<SelectItem value="status-summary"> </SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
@ -197,12 +200,13 @@ export function DashboardTopMenu({
<SelectItem value="document"></SelectItem>
<SelectItem value="risk-alert"> </SelectItem>
</SelectGroup>
<SelectGroup>
{/* 범용 위젯으로 대체 가능하여 주석처리 */}
{/* <SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="vehicle-status"> </SelectItem>
<SelectItem value="vehicle-list"> </SelectItem>
<SelectItem value="vehicle-map"> </SelectItem>
</SelectGroup>
</SelectGroup> */}
</SelectContent>
</Select>
</div>

View File

@ -98,7 +98,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
setQueryResult(result);
// 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋)
console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
// console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
setChartConfig({});
}, []);
@ -126,7 +126,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
showHeader, // 헤더 표시 여부
};
console.log(" 저장할 element:", updatedElement);
// console.log(" 저장할 element:", updatedElement);
onSave(updatedElement);
onClose();
@ -135,11 +135,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
// 모달이 열려있지 않으면 렌더링하지 않음
if (!isOpen) return null;
// 시계, 달력, 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) {
// 시계, 달력, To-Do 위젯은 헤더 설정만 가능
const isHeaderOnlyWidget = element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "todo");
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (element.type === "widget" && element.subtype === "driver-management") {
return null;
}
@ -212,16 +212,18 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder={"예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"}
onKeyDown={(e) => {
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
e.stopPropagation();
}}
placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">
💡 (: "maintenance_schedules 목록")
</p>
<p className="mt-1 text-xs text-gray-500"> (: "maintenance_schedules 목록")</p>
</div>
{/* 헤더 표시 여부 */}
<div className="mt-4 flex items-center">
{/* 헤더 표시 옵션 */}
<div className="mt-3 flex items-center gap-2">
<input
type="checkbox"
id="showHeader"
@ -229,14 +231,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
onChange={(e) => setShowHeader(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<label htmlFor="showHeader" className="ml-2 block text-sm text-gray-700">
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
( + )
</label>
</div>
</div>
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */}
{!isSimpleWidget && (
{/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */}
{!isSimpleWidget && !isHeaderOnlyWidget && (
<div className="border-b bg-gray-50 px-6 py-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-gray-700">
@ -247,12 +249,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
{/* 단계별 내용 */}
<div className="flex-1 overflow-auto p-6">
{currentStep === 1 && (
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
)}
{!isHeaderOnlyWidget && (
<div className="flex-1 overflow-auto p-6">
{currentStep === 1 && (
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
)}
{currentStep === 2 && (
{currentStep === 2 && (
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
{/* 왼쪽: 데이터 설정 */}
<div className="space-y-6">
@ -308,15 +311,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* 모달 푸터 */}
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
<div>{queryResult && <Badge variant="default">{queryResult.rows.length} </Badge>}</div>
<div className="flex gap-3">
{!isSimpleWidget && currentStep > 1 && (
{!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && (
<Button variant="outline" onClick={handlePrev}>
<ChevronLeft className="mr-2 h-4 w-4" />
@ -325,14 +329,20 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
<Button variant="outline" onClick={onClose}>
</Button>
{currentStep === 1 ? (
// 1단계: 다음 버튼 (모든 타입 공통)
{isHeaderOnlyWidget ? (
// 헤더 전용 위젯: 바로 저장
<Button onClick={handleSave}>
<Save className="mr-2 h-4 w-4" />
</Button>
) : currentStep === 1 ? (
// 1단계: 다음 버튼
<Button onClick={handleNext}>
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
) : (
// 2단계: 저장 버튼 (모든 타입 공통)
// 2단계: 저장 버튼
<Button onClick={handleSave} disabled={!canSave}>
<Save className="mr-2 h-4 w-4" />

View File

@ -61,7 +61,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
setUserMenus(userResponse.data || []);
}
} catch (error) {
console.error("메뉴 목록 로드 실패:", error);
// console.error("메뉴 목록 로드 실패:", error);
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);

View File

@ -35,9 +35,9 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 쿼리 실행
const executeQuery = useCallback(async () => {
console.log("🚀 executeQuery 호출됨!");
console.log("📝 현재 쿼리:", query);
console.log("✅ query.trim():", query.trim());
// console.log("🚀 executeQuery 호출됨!");
// console.log("📝 현재 쿼리:", query);
// console.log("✅ query.trim():", query.trim());
if (!query.trim()) {
setError("쿼리를 입력해주세요.");
@ -47,13 +47,13 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 외부 DB인 경우 커넥션 ID 확인
if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) {
setError("외부 DB 커넥션을 선택해주세요.");
console.log("❌ 쿼리가 비어있음!");
// console.log("❌ 쿼리가 비어있음!");
return;
}
setIsExecuting(true);
setError(null);
console.log("🔄 쿼리 실행 시작...");
// console.log("🔄 쿼리 실행 시작...");
try {
let apiResult: { columns: string[]; rows: any[]; rowCount: number };
@ -247,7 +247,7 @@ ORDER BY Q4 DESC;`,
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent className="z-[99999]">
<SelectItem value="0"></SelectItem>
<SelectItem value="10000">10</SelectItem>
<SelectItem value="30000">30</SelectItem>

View File

@ -0,0 +1,162 @@
/**
*
*/
import { DashboardElement } from "./types";
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
/**
* ( )
* @param rect1
* @param rect2
* @param cellSize (기본: 130px)
*/
export function isColliding(rect1: Rectangle, rect2: Rectangle, cellSize: number = 130): boolean {
// 겹친 영역 계산
const overlapX = Math.max(
0,
Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - Math.max(rect1.x, rect2.x)
);
const overlapY = Math.max(
0,
Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - Math.max(rect1.y, rect2.y)
);
// 큰 그리드의 절반(cellSize/2 ≈ 65px) 이상 겹쳐야 충돌로 간주
const collisionThreshold = Math.floor(cellSize / 2);
return overlapX >= collisionThreshold && overlapY >= collisionThreshold;
}
/**
*
*/
export function findCollisions(
element: DashboardElement,
allElements: DashboardElement[],
cellSize: number = 130,
excludeId?: string
): DashboardElement[] {
const elementRect: Rectangle = {
x: element.position.x,
y: element.position.y,
width: element.size.width,
height: element.size.height,
};
return allElements.filter((other) => {
if (other.id === element.id || other.id === excludeId) {
return false;
}
const otherRect: Rectangle = {
x: other.position.x,
y: other.position.y,
width: other.size.width,
height: other.size.height,
};
return isColliding(elementRect, otherRect, cellSize);
});
}
/**
*
*/
export function resolveCollisionVertically(
movingElement: DashboardElement,
collidingElement: DashboardElement,
gridSize: number = 10
): { x: number; y: number } {
// 충돌하는 위젯 아래로 이동
const newY = collidingElement.position.y + collidingElement.size.height + gridSize;
return {
x: collidingElement.position.x,
y: Math.round(newY / gridSize) * gridSize, // 그리드에 스냅
};
}
/**
*
*/
export function resolveAllCollisions(
elements: DashboardElement[],
movedElementId: string,
subGridSize: number = 10,
canvasWidth: number = 1560,
cellSize: number = 130,
maxIterations: number = 50
): DashboardElement[] {
let result = [...elements];
let iterations = 0;
// 이동한 위젯부터 시작
const movedIndex = result.findIndex((el) => el.id === movedElementId);
if (movedIndex === -1) return result;
// Y 좌표로 정렬 (위에서 아래로 처리)
const sortedIndices = result
.map((el, idx) => ({ el, idx }))
.sort((a, b) => a.el.position.y - b.el.position.y)
.map((item) => item.idx);
while (iterations < maxIterations) {
let hasCollision = false;
for (const idx of sortedIndices) {
const element = result[idx];
const collisions = findCollisions(element, result, cellSize);
if (collisions.length > 0) {
hasCollision = true;
// 첫 번째 충돌만 처리 (가장 위에 있는 것)
const collision = collisions.sort((a, b) => a.position.y - b.position.y)[0];
// 충돌하는 위젯을 아래로 이동
const collisionIdx = result.findIndex((el) => el.id === collision.id);
if (collisionIdx !== -1) {
const newY = element.position.y + element.size.height + subGridSize;
result[collisionIdx] = {
...result[collisionIdx],
position: {
...result[collisionIdx].position,
y: Math.round(newY / subGridSize) * subGridSize,
},
};
}
}
}
if (!hasCollision) break;
iterations++;
}
return result;
}
/**
*
*/
export function constrainToCanvas(
element: DashboardElement,
canvasWidth: number,
canvasHeight: number,
gridSize: number = 10
): { x: number; y: number } {
const maxX = canvasWidth - element.size.width;
const maxY = canvasHeight - element.size.height;
return {
x: Math.max(0, Math.min(Math.round(element.position.x / gridSize) * gridSize, maxX)),
y: Math.max(0, Math.min(Math.round(element.position.y / gridSize) * gridSize, maxY)),
};
}

View File

@ -8,9 +8,10 @@
// 기본 그리드 설정 (FHD 기준)
export const GRID_CONFIG = {
COLUMNS: 12, // 모든 해상도에서 12칸 고정
GAP: 8, // 셀 간격 고정
SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
GAP: 5, // 셀 간격 고정
SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용)
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
} as const;
@ -23,14 +24,23 @@ export function calculateCellSize(canvasWidth: number): number {
return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
}
/**
* ( )
*/
export function calculateSubGridSize(cellSize: number): number {
return Math.floor(cellSize / GRID_CONFIG.SUB_GRID_DIVISIONS);
}
/**
*
*/
export function calculateGridConfig(canvasWidth: number) {
const cellSize = calculateCellSize(canvasWidth);
const subGridSize = calculateSubGridSize(cellSize);
return {
...GRID_CONFIG,
CELL_SIZE: cellSize,
SUB_GRID_SIZE: subGridSize,
CANVAS_WIDTH: canvasWidth,
};
}
@ -51,15 +61,18 @@ export const getCanvasWidth = () => {
};
/**
* ( )
* ( )
* @param value -
* @param cellSize - (, GRID_CONFIG.CELL_SIZE)
* @returns ( )
* @param subGridSize - (, 기본값: cellSize/3 43px)
* @returns
*/
export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const gridIndex = Math.round(value / cellWithGap);
return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING;
export const snapToGrid = (value: number, subGridSize?: number): number => {
// 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드)
const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3);
// 서브 그리드 단위로 스냅
const gridIndex = Math.round(value / snapSize);
return gridIndex * snapSize;
};
/**

View File

@ -36,7 +36,7 @@ export type ElementSubtype =
| "maintenance"
| "document"
| "list"
| "warehouse-3d"; // 위젯 타입
| "yard-management-3d"; // 야드 관리 3D 위젯
export interface Position {
x: number;
@ -64,6 +64,7 @@ export interface DashboardElement {
calendarConfig?: CalendarConfig; // 달력 설정
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
}
export interface DragData {
@ -272,3 +273,9 @@ export interface ListColumn {
align?: "left" | "center" | "right"; // 정렬
visible?: boolean; // 표시 여부 (기본: true)
}
// 야드 관리 3D 설정
export interface YardManagementConfig {
layoutId: number; // 선택된 야드 레이아웃 ID
layoutName?: string; // 레이아웃 이름 (표시용)
}

View File

@ -8,6 +8,7 @@ import { generateCalendarDays, getMonthName, navigateMonth } from "./calendarUti
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Settings, ChevronLeft, ChevronRight, Calendar } from "lucide-react";
import { useDashboard } from "@/contexts/DashboardContext";
interface CalendarWidgetProps {
element: DashboardElement;
@ -21,11 +22,19 @@ interface CalendarWidgetProps {
* - UI
*/
export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) {
// Context에서 선택된 날짜 관리
const { selectedDate, setSelectedDate } = useDashboard();
// 현재 표시 중인 년/월
const today = new Date();
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [settingsOpen, setSettingsOpen] = useState(false);
// 날짜 클릭 핸들러
const handleDateClick = (date: Date) => {
setSelectedDate(date);
};
// 기본 설정값
const config = element.calendarConfig || {
@ -98,7 +107,15 @@ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps)
{/* 달력 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{config.view === "month" && <MonthView days={calendarDays} config={config} isCompact={isCompact} />}
{config.view === "month" && (
<MonthView
days={calendarDays}
config={config}
isCompact={isCompact}
selectedDate={selectedDate}
onDateClick={handleDateClick}
/>
)}
{/* 추후 WeekView, DayView 추가 가능 */}
</div>

View File

@ -219,8 +219,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
return (
<div className="flex h-full w-full flex-col p-4">
{/* 제목 - 항상 표시 */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-gray-700">{element.title}</h3>
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
</div>
{/* 테이블 뷰 */}

View File

@ -131,7 +131,7 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
// 저장
const handleSave = () => {
onSave({
title,
customTitle: title,
dataSource,
listConfig,
});
@ -166,10 +166,19 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
id="list-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
e.stopPropagation();
}}
placeholder="예: 사용자 목록"
className="mt-1"
/>
</div>
{/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */}
<div className="rounded bg-blue-50 p-2 text-xs text-blue-700">
💡
</div>
</div>
{/* 진행 상태 표시 */}

View File

@ -7,12 +7,14 @@ interface MonthViewProps {
days: CalendarDay[];
config: CalendarConfig;
isCompact?: boolean; // 작은 크기 (2x2, 3x3)
selectedDate?: Date | null; // 선택된 날짜
onDateClick?: (date: Date) => void; // 날짜 클릭 핸들러
}
/**
*
*/
export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
export function MonthView({ days, config, isCompact = false, selectedDate, onDateClick }: MonthViewProps) {
const weekDayNames = getWeekDayNames(config.startWeekOn);
// 테마별 스타일
@ -43,10 +45,27 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
const themeStyles = getThemeStyles();
// 날짜가 선택된 날짜인지 확인
const isSelected = (day: CalendarDay) => {
if (!selectedDate || !day.isCurrentMonth) return false;
return (
selectedDate.getFullYear() === day.date.getFullYear() &&
selectedDate.getMonth() === day.date.getMonth() &&
selectedDate.getDate() === day.date.getDate()
);
};
// 날짜 클릭 핸들러
const handleDayClick = (day: CalendarDay) => {
if (!day.isCurrentMonth || !onDateClick) return;
onDateClick(day.date);
};
// 날짜 셀 스타일 클래스
const getDayCellClass = (day: CalendarDay) => {
const baseClass = "flex aspect-square items-center justify-center rounded-lg transition-colors";
const sizeClass = isCompact ? "text-xs" : "text-sm";
const cursorClass = day.isCurrentMonth ? "cursor-pointer" : "cursor-default";
let colorClass = "text-gray-700";
@ -54,6 +73,10 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
if (!day.isCurrentMonth) {
colorClass = "text-gray-300";
}
// 선택된 날짜
else if (isSelected(day)) {
colorClass = "text-white font-bold";
}
// 오늘
else if (config.highlightToday && day.isToday) {
colorClass = "text-white font-bold";
@ -67,9 +90,16 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
colorClass = "text-red-600";
}
const bgClass = config.highlightToday && day.isToday ? "" : "hover:bg-gray-100";
let bgClass = "";
if (isSelected(day)) {
bgClass = ""; // 선택된 날짜는 배경색이 style로 적용됨
} else if (config.highlightToday && day.isToday) {
bgClass = "";
} else {
bgClass = "hover:bg-gray-100";
}
return `${baseClass} ${sizeClass} ${colorClass} ${bgClass}`;
return `${baseClass} ${sizeClass} ${colorClass} ${bgClass} ${cursorClass}`;
};
return (
@ -97,9 +127,13 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
<div
key={index}
className={getDayCellClass(day)}
onClick={() => handleDayClick(day)}
style={{
backgroundColor:
config.highlightToday && day.isToday ? themeStyles.todayBg : undefined,
backgroundColor: isSelected(day)
? "#10b981" // 선택된 날짜는 초록색
: config.highlightToday && day.isToday
? themeStyles.todayBg
: undefined,
color:
config.showHolidays && day.isHoliday && day.isCurrentMonth
? themeStyles.holidayText

View File

@ -0,0 +1,336 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { DashboardElement, ChartDataSource, QueryResult } from "../types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
import { DataSourceSelector } from "../data-sources/DataSourceSelector";
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
import { ApiConfig } from "../data-sources/ApiConfig";
import { QueryEditor } from "../QueryEditor";
interface TodoWidgetConfigModalProps {
isOpen: boolean;
element: DashboardElement;
onClose: () => void;
onSave: (updates: Partial<DashboardElement>) => void;
}
/**
* To-Do
* - 2 설정: 데이터 /
*/
export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: TodoWidgetConfigModalProps) {
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
const [title, setTitle] = useState(element.title || "✅ To-Do / 긴급 지시");
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
);
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
// 모달 열릴 때 element에서 설정 로드
useEffect(() => {
if (isOpen) {
setTitle(element.title || "✅ To-Do / 긴급 지시");
if (element.dataSource) {
setDataSource(element.dataSource);
}
setCurrentStep(1);
}
}, [isOpen, element.id]);
// 데이터 소스 타입 변경
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
if (type === "database") {
setDataSource((prev) => ({
...prev,
type: "database",
connectionType: "current",
}));
} else {
setDataSource((prev) => ({
...prev,
type: "api",
method: "GET",
}));
}
setQueryResult(null);
}, []);
// 데이터 소스 업데이트
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
setDataSource((prev) => ({ ...prev, ...updates }));
}, []);
// 쿼리 실행 결과 처리
const handleQueryTest = useCallback(
(result: QueryResult) => {
// console.log("🎯 TodoWidget - handleQueryTest 호출됨!");
// console.log("📊 쿼리 결과:", result);
// console.log("📝 rows 개수:", result.rows?.length);
// console.log("❌ error:", result.error);
setQueryResult(result);
// console.log("✅ setQueryResult 호출 완료!");
// 강제 리렌더링 확인
// setTimeout(() => {
// console.log("🔄 1초 후 queryResult 상태:", result);
// }, 1000);
},
[],
);
// 저장
const handleSave = useCallback(() => {
if (!dataSource.query || !queryResult || queryResult.error) {
alert("쿼리를 입력하고 테스트를 먼저 실행해주세요.");
return;
}
if (!queryResult.rows || queryResult.rows.length === 0) {
alert("쿼리 결과가 없습니다. 데이터가 반환되는 쿼리를 입력해주세요.");
return;
}
onSave({
title,
dataSource,
});
onClose();
}, [title, dataSource, queryResult, onSave, onClose]);
// 다음 단계로
const handleNext = useCallback(() => {
if (currentStep === 1) {
if (dataSource.type === "database") {
if (!dataSource.connectionId && dataSource.connectionType === "external") {
alert("외부 데이터베이스를 선택해주세요.");
return;
}
} else if (dataSource.type === "api") {
if (!dataSource.url) {
alert("API URL을 입력해주세요.");
return;
}
}
setCurrentStep(2);
}
}, [currentStep, dataSource]);
// 이전 단계로
const handlePrev = useCallback(() => {
if (currentStep === 2) {
setCurrentStep(1);
}
}, [currentStep]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
<div className="relative flex h-[90vh] w-[90vw] max-w-6xl flex-col rounded-lg bg-white shadow-xl">
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 className="text-xl font-bold text-gray-800">To-Do </h2>
<p className="mt-1 text-sm text-gray-500">
To-Do
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
>
<X className="h-5 w-5" />
</button>
</div>
{/* 진행 상태 */}
<div className="border-b border-gray-200 bg-gray-50 px-6 py-3">
<div className="flex items-center gap-4">
<div className={`flex items-center gap-2 ${currentStep === 1 ? "text-primary" : "text-gray-400"}`}>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
currentStep === 1 ? "bg-primary text-white" : "bg-gray-200"
}`}
>
1
</div>
<span className="font-medium"> </span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
<div className={`flex items-center gap-2 ${currentStep === 2 ? "text-primary" : "text-gray-400"}`}>
<div
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
currentStep === 2 ? "bg-primary text-white" : "bg-gray-200"
}`}
>
2
</div>
<span className="font-medium"> </span>
</div>
</div>
</div>
{/* 본문 */}
<div className="flex-1 overflow-y-auto p-6">
{/* Step 1: 데이터 소스 선택 */}
{currentStep === 1 && (
<div className="space-y-6">
<div>
<Label className="text-base font-semibold"></Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: ✅ 오늘의 할 일"
className="mt-2"
/>
</div>
<div>
<Label className="text-base font-semibold"> </Label>
<DataSourceSelector
dataSource={dataSource}
onTypeChange={handleDataSourceTypeChange}
/>
</div>
{dataSource.type === "database" && (
<DatabaseConfig dataSource={dataSource} onUpdate={handleDataSourceUpdate} />
)}
{dataSource.type === "api" && <ApiConfig dataSource={dataSource} onUpdate={handleDataSourceUpdate} />}
</div>
)}
{/* Step 2: 쿼리 입력 및 테스트 */}
{currentStep === 2 && (
<div className="space-y-6">
<div>
<div className="mb-4 rounded-lg bg-blue-50 p-4">
<h3 className="mb-2 font-semibold text-blue-900">💡 </h3>
<p className="mb-2 text-sm text-blue-700">
To-Do :
</p>
<ul className="space-y-1 text-sm text-blue-600">
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">id</code> - ID ( )
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">title</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">task</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">name</code> - ()
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">description</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">desc</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">content</code> -
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">priority</code> - (urgent, high,
normal, low)
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">status</code> - (pending, in_progress,
completed)
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">assigned_to</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">assignedTo</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">user</code> -
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">due_date</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">dueDate</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">deadline</code> -
</li>
<li>
<code className="rounded bg-blue-100 px-1 py-0.5">is_urgent</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">isUrgent</code>,{" "}
<code className="rounded bg-blue-100 px-1 py-0.5">urgent</code> -
</li>
</ul>
</div>
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
</div>
{/* 디버그: 항상 표시되는 테스트 메시지 */}
<div className="mt-4 rounded-lg bg-yellow-50 border-2 border-yellow-500 p-4">
<p className="text-sm font-bold text-yellow-900">
🔍 디버그: queryResult = {queryResult ? "있음" : "없음"}
</p>
{queryResult && (
<p className="text-xs text-yellow-700 mt-1">
rows: {queryResult.rows?.length}, error: {queryResult.error || "없음"}
</p>
)}
</div>
{queryResult && !queryResult.error && queryResult.rows && queryResult.rows.length > 0 && (
<div className="mt-4 rounded-lg bg-green-50 border-2 border-green-500 p-4">
<h3 className="mb-2 font-semibold text-green-900"> !</h3>
<p className="text-sm text-green-700">
<strong>{queryResult.rows.length}</strong> To-Do .
</p>
<div className="mt-3 rounded bg-white p-3">
<p className="mb-2 text-xs font-semibold text-gray-600"> :</p>
<pre className="overflow-x-auto text-xs text-gray-700">
{JSON.stringify(queryResult.rows[0], null, 2)}
</pre>
</div>
</div>
)}
</div>
)}
</div>
{/* 하단 버튼 */}
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
<div>
{currentStep > 1 && (
<Button onClick={handlePrev} variant="outline">
<ChevronLeft className="mr-1 h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-2">
<Button onClick={onClose} variant="outline">
</Button>
{currentStep < 2 ? (
<Button onClick={handleNext}>
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
) : (
<Button
onClick={handleSave}
disabled={(() => {
const isDisabled = !queryResult || queryResult.error || !queryResult.rows || queryResult.rows.length === 0;
// console.log("💾 저장 버튼 disabled:", isDisabled);
// console.log("💾 queryResult:", queryResult);
return isDisabled;
})()}
>
<Save className="mr-1 h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,418 +0,0 @@
"use client";
import React, { useRef, useState, useEffect, Suspense } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Text, Box, Html } from "@react-three/drei";
import * as THREE from "three";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2, Maximize2, Info } from "lucide-react";
interface WarehouseData {
id: string;
name: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
capacity: number;
current_usage: number;
status: string;
description?: string;
}
interface MaterialData {
id: string;
warehouse_id: string;
name: string;
material_code: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
status: string;
}
interface Warehouse3DWidgetProps {
element?: any;
}
// 창고 3D 박스 컴포넌트
function WarehouseBox({
warehouse,
onClick,
isSelected,
}: {
warehouse: WarehouseData;
onClick: () => void;
isSelected: boolean;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
useFrame(() => {
if (meshRef.current) {
if (isSelected) {
meshRef.current.scale.lerp(new THREE.Vector3(1.05, 1.05, 1.05), 0.1);
} else if (hovered) {
meshRef.current.scale.lerp(new THREE.Vector3(1.02, 1.02, 1.02), 0.1);
} else {
meshRef.current.scale.lerp(new THREE.Vector3(1, 1, 1), 0.1);
}
}
});
const usagePercentage = (warehouse.current_usage / warehouse.capacity) * 100;
return (
<group position={[warehouse.position_x, warehouse.position_y + warehouse.size_y / 2, warehouse.position_z]}>
<mesh
ref={meshRef}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[warehouse.size_x, warehouse.size_y, warehouse.size_z]} />
<meshStandardMaterial color={warehouse.color} transparent opacity={0.3} wireframe={false} />
</mesh>
{/* 창고 테두리 */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(warehouse.size_x, warehouse.size_y, warehouse.size_z)]} />
<lineBasicMaterial color={isSelected ? "#FFD700" : hovered ? "#FFFFFF" : warehouse.color} linewidth={2} />
</lineSegments>
{/* 창고 이름 라벨 */}
<Text position={[0, warehouse.size_y / 2 + 1, 0]} fontSize={1} color="white" anchorX="center" anchorY="middle">
{warehouse.name}
</Text>
{/* 사용률 표시 */}
<Html position={[0, warehouse.size_y / 2 + 2, 0]} center>
<div className="pointer-events-none rounded bg-black/80 px-2 py-1 text-xs text-white">
{usagePercentage.toFixed(0)}%
</div>
</Html>
</group>
);
}
// 자재 3D 박스 컴포넌트
function MaterialBox({
material,
onClick,
isSelected,
}: {
material: MaterialData;
onClick: () => void;
isSelected: boolean;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const [hovered, setHovered] = useState(false);
useFrame(() => {
if (meshRef.current && (isSelected || hovered)) {
meshRef.current.rotation.y += 0.01;
}
});
const statusColor =
{
stocked: material.color,
reserved: "#FFA500",
urgent: "#FF0000",
out_of_stock: "#808080",
}[material.status] || material.color;
return (
<group position={[material.position_x, material.position_y + material.size_y / 2, material.position_z]}>
<mesh
ref={meshRef}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<boxGeometry args={[material.size_x, material.size_y, material.size_z]} />
<meshStandardMaterial color={statusColor} metalness={0.5} roughness={0.2} />
</mesh>
{(hovered || isSelected) && (
<Html position={[0, material.size_y / 2 + 0.5, 0]} center>
<div className="pointer-events-none rounded bg-black/90 px-2 py-1 text-xs text-white shadow-lg">
<div className="font-bold">{material.name}</div>
<div className="text-gray-300">
{material.quantity} {material.unit}
</div>
</div>
</Html>
)}
</group>
);
}
// 3D 씬 컴포넌트
function Scene({
warehouses,
materials,
onSelectWarehouse,
onSelectMaterial,
selectedWarehouse,
selectedMaterial,
}: {
warehouses: WarehouseData[];
materials: MaterialData[];
onSelectWarehouse: (warehouse: WarehouseData | null) => void;
onSelectMaterial: (material: MaterialData | null) => void;
selectedWarehouse: WarehouseData | null;
selectedMaterial: MaterialData | null;
}) {
return (
<>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-10, 10, -5]} intensity={0.5} />
{/* 바닥 그리드 */}
<gridHelper args={[100, 50, "#444444", "#222222"]} position={[0, 0, 0]} />
{/* 창고들 */}
{warehouses.map((warehouse) => (
<WarehouseBox
key={warehouse.id}
warehouse={warehouse}
onClick={() => {
if (selectedWarehouse?.id === warehouse.id) {
onSelectWarehouse(null);
} else {
onSelectWarehouse(warehouse);
onSelectMaterial(null);
}
}}
isSelected={selectedWarehouse?.id === warehouse.id}
/>
))}
{/* 자재들 */}
{materials.map((material) => (
<MaterialBox
key={material.id}
material={material}
onClick={() => {
if (selectedMaterial?.id === material.id) {
onSelectMaterial(null);
} else {
onSelectMaterial(material);
}
}}
isSelected={selectedMaterial?.id === material.id}
/>
))}
{/* 카메라 컨트롤 */}
<OrbitControls enableDamping dampingFactor={0.05} minDistance={10} maxDistance={100} />
</>
);
}
export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) {
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [loading, setLoading] = useState(true);
const [selectedWarehouse, setSelectedWarehouse] = useState<WarehouseData | null>(null);
const [selectedMaterial, setSelectedMaterial] = useState<MaterialData | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
// API 호출 (백엔드 API 구현 필요)
const response = await fetch("/api/warehouse/data");
if (response.ok) {
const data = await response.json();
setWarehouses(data.warehouses || []);
setMaterials(data.materials || []);
} else {
// 임시 더미 데이터 (개발용)
console.log("API 실패, 더미 데이터 사용");
}
} catch (error) {
console.error("창고 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Card className="h-full">
<CardContent className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</CardContent>
</Card>
);
}
return (
<Card className={`flex h-full flex-col ${isFullscreen ? "fixed inset-0 z-50" : ""}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-bold">🏭 (3D)</CardTitle>
<div className="flex gap-2">
<Badge variant="outline">
{warehouses.length} | {materials.length}
</Badge>
<button onClick={() => setIsFullscreen(!isFullscreen)} className="text-gray-500 hover:text-gray-700">
<Maximize2 className="h-4 w-4" />
</button>
</div>
</CardHeader>
<CardContent className="flex flex-1 gap-4 p-4">
{/* 3D 뷰 */}
<div className="flex-1 rounded-lg bg-gray-900">
<Canvas camera={{ position: [30, 20, 30], fov: 50 }}>
<Suspense fallback={null}>
<Scene
warehouses={warehouses}
materials={materials}
onSelectWarehouse={setSelectedWarehouse}
onSelectMaterial={setSelectedMaterial}
selectedWarehouse={selectedWarehouse}
selectedMaterial={selectedMaterial}
/>
</Suspense>
</Canvas>
</div>
{/* 정보 패널 */}
<div className="w-80 space-y-4 overflow-y-auto">
{/* 선택된 창고 정보 */}
{selectedWarehouse && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Info className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<span className="font-semibold">:</span> {selectedWarehouse.name}
</div>
<div>
<span className="font-semibold">ID:</span> {selectedWarehouse.id}
</div>
<div>
<span className="font-semibold">:</span> {selectedWarehouse.current_usage} /{" "}
{selectedWarehouse.capacity}
</div>
<div>
<span className="font-semibold">:</span>{" "}
{((selectedWarehouse.current_usage / selectedWarehouse.capacity) * 100).toFixed(1)}%
</div>
<div>
<span className="font-semibold">:</span>{" "}
<Badge variant={selectedWarehouse.status === "active" ? "default" : "secondary"}>
{selectedWarehouse.status}
</Badge>
</div>
{selectedWarehouse.description && (
<div>
<span className="font-semibold">:</span> {selectedWarehouse.description}
</div>
)}
</CardContent>
</Card>
)}
{/* 선택된 자재 정보 */}
{selectedMaterial && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Info className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<span className="font-semibold">:</span> {selectedMaterial.name}
</div>
<div>
<span className="font-semibold">:</span> {selectedMaterial.material_code}
</div>
<div>
<span className="font-semibold">:</span> {selectedMaterial.quantity} {selectedMaterial.unit}
</div>
<div>
<span className="font-semibold">:</span>{" "}
{warehouses.find((w) => w.id === selectedMaterial.warehouse_id)?.name}
</div>
<div>
<span className="font-semibold">:</span>{" "}
<Badge
variant={
selectedMaterial.status === "urgent"
? "destructive"
: selectedMaterial.status === "reserved"
? "secondary"
: "default"
}
>
{selectedMaterial.status}
</Badge>
</div>
</CardContent>
</Card>
)}
{/* 창고 목록 */}
{!selectedWarehouse && !selectedMaterial && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{warehouses.map((warehouse) => {
const warehouseMaterials = materials.filter((m) => m.warehouse_id === warehouse.id);
return (
<button
key={warehouse.id}
onClick={() => setSelectedWarehouse(warehouse)}
className="w-full rounded-lg border p-2 text-left transition-colors hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<span className="font-semibold">{warehouse.name}</span>
<Badge variant="outline">{warehouseMaterials.length}</Badge>
</div>
<div className="mt-1 text-xs text-gray-500">
{((warehouse.current_usage / warehouse.capacity) * 100).toFixed(0)}%
</div>
</button>
);
})}
</CardContent>
</Card>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,198 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Plus, Check } from "lucide-react";
import YardLayoutList from "./yard-3d/YardLayoutList";
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
import YardEditor from "./yard-3d/YardEditor";
import Yard3DViewer from "./yard-3d/Yard3DViewer";
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
import type { YardManagementConfig } from "../types";
interface YardLayout {
id: number;
name: string;
description: string;
placement_count: number;
updated_at: string;
}
interface YardManagement3DWidgetProps {
isEditMode?: boolean;
config?: YardManagementConfig;
onConfigChange?: (config: YardManagementConfig) => void;
}
export default function YardManagement3DWidget({
isEditMode = false,
config,
onConfigChange,
}: YardManagement3DWidgetProps) {
const [layouts, setLayouts] = useState<YardLayout[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingLayout, setEditingLayout] = useState<YardLayout | null>(null);
// 레이아웃 목록 로드
const loadLayouts = async () => {
try {
setIsLoading(true);
const response = await yardLayoutApi.getAllLayouts();
if (response.success) {
setLayouts(response.data);
}
} catch (error) {
console.error("야드 레이아웃 목록 조회 실패:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isEditMode) {
loadLayouts();
}
}, [isEditMode]);
// 레이아웃 선택 (편집 모드에서만)
const handleSelectLayout = (layout: YardLayout) => {
if (onConfigChange) {
onConfigChange({
layoutId: layout.id,
layoutName: layout.name,
});
}
};
// 새 레이아웃 생성
const handleCreateLayout = async (name: string, description: string) => {
try {
const response = await yardLayoutApi.createLayout({ name, description });
if (response.success) {
await loadLayouts();
setIsCreateModalOpen(false);
setEditingLayout(response.data);
}
} catch (error) {
console.error("야드 레이아웃 생성 실패:", error);
throw error;
}
};
// 편집 완료
const handleEditComplete = () => {
if (editingLayout && onConfigChange) {
onConfigChange({
layoutId: editingLayout.id,
layoutName: editingLayout.name,
});
}
setEditingLayout(null);
loadLayouts();
};
// 편집 모드: 편집 중인 경우 YardEditor 표시
if (isEditMode && editingLayout) {
return (
<div className="h-full w-full">
<YardEditor layout={editingLayout} onBack={handleEditComplete} />
</div>
);
}
// 편집 모드: 레이아웃 선택 UI
if (isEditMode) {
return (
<div className="flex h-full w-full flex-col bg-white">
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-sm font-semibold text-gray-700"> </h3>
<p className="mt-1 text-xs text-gray-500">
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
</p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)} size="sm">
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto p-4">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-gray-500"> ...</div>
</div>
) : layouts.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-4xl">🏗</div>
<div className="text-sm text-gray-600"> </div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
) : (
<div className="grid gap-3">
{layouts.map((layout) => (
<div
key={layout.id}
className={`rounded-lg border p-3 transition-all ${
config?.layoutId === layout.id ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
}`}
>
<div className="flex items-start justify-between gap-3">
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{layout.name}</span>
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-blue-600" />}
</div>
{layout.description && <p className="mt-1 text-xs text-gray-500">{layout.description}</p>}
<div className="mt-2 text-xs text-gray-400"> : {layout.placement_count}</div>
</button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setEditingLayout(layout);
}}
>
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 생성 모달 */}
<YardLayoutCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreateLayout}
/>
</div>
);
}
// 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시
if (!config?.layoutId) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl">🏗</div>
<div className="text-sm font-medium text-gray-600"> </div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
);
}
// 선택된 레이아웃의 3D 뷰어 표시
return (
<div className="h-full w-full">
<Yard3DViewer layoutId={config.layoutId} />
</div>
);
}

View File

@ -0,0 +1,247 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
interface TempMaterial {
id: number;
material_code: string;
material_name: string;
category: string;
unit: string;
default_color: string;
description: string;
}
interface MaterialAddModalProps {
isOpen: boolean;
material: TempMaterial | null;
onClose: () => void;
onAdd: (placementData: any) => Promise<void>;
}
export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: MaterialAddModalProps) {
const [quantity, setQuantity] = useState("1");
const [positionX, setPositionX] = useState("0");
const [positionY, setPositionY] = useState("0");
const [positionZ, setPositionZ] = useState("0");
const [sizeX, setSizeX] = useState("5");
const [sizeY, setSizeY] = useState("5");
const [sizeZ, setSizeZ] = useState("5");
const [color, setColor] = useState("");
const [isAdding, setIsAdding] = useState(false);
// 모달이 열릴 때 기본값 설정
const handleOpen = (open: boolean) => {
if (open && material) {
setColor(material.default_color);
setQuantity("1");
setPositionX("0");
setPositionY("0");
setPositionZ("0");
setSizeX("5");
setSizeY("5");
setSizeZ("5");
}
};
// 자재 추가
const handleAdd = async () => {
if (!material) return;
setIsAdding(true);
try {
await onAdd({
external_material_id: `TEMP-${Date.now()}`,
material_code: material.material_code,
material_name: material.material_name,
quantity: parseInt(quantity) || 1,
unit: material.unit,
position_x: parseFloat(positionX) || 0,
position_y: parseFloat(positionY) || 0,
position_z: parseFloat(positionZ) || 0,
size_x: parseFloat(sizeX) || 5,
size_y: parseFloat(sizeY) || 5,
size_z: parseFloat(sizeZ) || 5,
color: color || material.default_color,
});
onClose();
} catch (error) {
console.error("자재 추가 실패:", error);
} finally {
setIsAdding(false);
}
};
if (!material) return null;
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
handleOpen(open);
if (!open) onClose();
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 자재 정보 */}
<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-sm font-medium text-gray-600"> </div>
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded border" style={{ backgroundColor: material.default_color }} />
<div>
<div className="font-medium">{material.material_name}</div>
<div className="text-sm text-gray-600">{material.material_code}</div>
</div>
</div>
</div>
{/* 수량 */}
<div className="space-y-2">
<Label htmlFor="quantity"></Label>
<div className="flex items-center gap-2">
<Input
id="quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
min="1"
className="flex-1"
/>
<span className="text-sm text-gray-600">{material.unit}</span>
</div>
</div>
{/* 3D 위치 */}
<div className="space-y-2">
<Label>3D </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="posX" className="text-xs text-gray-600">
X ()
</Label>
<Input
id="posX"
type="number"
value={positionX}
onChange={(e) => setPositionX(e.target.value)}
step="0.5"
/>
</div>
<div>
<Label htmlFor="posY" className="text-xs text-gray-600">
Y ()
</Label>
<Input
id="posY"
type="number"
value={positionY}
onChange={(e) => setPositionY(e.target.value)}
step="0.5"
/>
</div>
<div>
<Label htmlFor="posZ" className="text-xs text-gray-600">
Z ()
</Label>
<Input
id="posZ"
type="number"
value={positionZ}
onChange={(e) => setPositionZ(e.target.value)}
step="0.5"
/>
</div>
</div>
</div>
{/* 3D 크기 */}
<div className="space-y-2">
<Label>3D </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="sizeX" className="text-xs text-gray-600">
</Label>
<Input
id="sizeX"
type="number"
value={sizeX}
onChange={(e) => setSizeX(e.target.value)}
min="1"
step="0.5"
/>
</div>
<div>
<Label htmlFor="sizeY" className="text-xs text-gray-600">
</Label>
<Input
id="sizeY"
type="number"
value={sizeY}
onChange={(e) => setSizeY(e.target.value)}
min="1"
step="0.5"
/>
</div>
<div>
<Label htmlFor="sizeZ" className="text-xs text-gray-600">
</Label>
<Input
id="sizeZ"
type="number"
value={sizeZ}
onChange={(e) => setSizeZ(e.target.value)}
min="1"
step="0.5"
/>
</div>
</div>
</div>
{/* 색상 */}
<div className="space-y-2">
<Label htmlFor="color"></Label>
<div className="flex items-center gap-2">
<input
id="color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-10 w-20 cursor-pointer rounded border"
/>
<Input value={color} onChange={(e) => setColor(e.target.value)} className="flex-1" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isAdding}>
</Button>
<Button onClick={handleAdd} disabled={isAdding}>
{isAdding ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"배치"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,277 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Trash2 } from "lucide-react";
interface YardPlacement {
id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
memo?: string;
}
interface MaterialEditPanelProps {
placement: YardPlacement | null;
onClose: () => void;
onUpdate: (id: number, updates: Partial<YardPlacement>) => void;
onRemove: (id: number) => void;
}
export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemove }: MaterialEditPanelProps) {
const [editData, setEditData] = useState<Partial<YardPlacement>>({});
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
// placement 변경 시 editData 초기화
useEffect(() => {
if (placement) {
setEditData({
position_x: placement.position_x,
position_y: placement.position_y,
position_z: placement.position_z,
size_x: placement.size_x,
size_y: placement.size_y,
size_z: placement.size_z,
color: placement.color,
memo: placement.memo,
});
}
}, [placement]);
if (!placement) return null;
// 변경사항 적용
const handleApply = () => {
onUpdate(placement.id, editData);
};
// 배치 해제
const handleRemove = () => {
onRemove(placement.id);
setIsDeleteDialogOpen(false);
};
return (
<div className="w-80 border-l bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold"> </h3>
<Button variant="ghost" size="sm" onClick={onClose}>
</Button>
</div>
<div className="space-y-4">
{/* 읽기 전용 정보 */}
<div className="space-y-3 rounded-lg bg-gray-50 p-3">
<div className="text-xs font-medium text-gray-500"> ( )</div>
<div>
<div className="text-xs text-gray-600"> </div>
<div className="mt-1 text-sm font-medium">{placement.material_code}</div>
</div>
<div>
<div className="text-xs text-gray-600"> </div>
<div className="mt-1 text-sm font-medium">{placement.material_name}</div>
</div>
<div>
<div className="text-xs text-gray-600"></div>
<div className="mt-1 text-sm font-medium">
{placement.quantity} {placement.unit}
</div>
</div>
</div>
{/* 배치 정보 (편집 가능) */}
<div className="space-y-3">
<div className="text-xs font-medium text-gray-500"> ( )</div>
{/* 3D 위치 */}
<div>
<Label className="text-xs"></Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="edit-posX" className="text-xs text-gray-600">
X
</Label>
<Input
id="edit-posX"
type="number"
value={editData.position_x ?? placement.position_x}
onChange={(e) => setEditData({ ...editData, position_x: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-posY" className="text-xs text-gray-600">
Y
</Label>
<Input
id="edit-posY"
type="number"
value={editData.position_y ?? placement.position_y}
onChange={(e) => setEditData({ ...editData, position_y: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-posZ" className="text-xs text-gray-600">
Z
</Label>
<Input
id="edit-posZ"
type="number"
value={editData.position_z ?? placement.position_z}
onChange={(e) => setEditData({ ...editData, position_z: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
</div>
</div>
{/* 3D 크기 */}
<div>
<Label className="text-xs"></Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="edit-sizeX" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeX"
type="number"
value={editData.size_x ?? placement.size_x}
onChange={(e) => setEditData({ ...editData, size_x: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-sizeY" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeY"
type="number"
value={editData.size_y ?? placement.size_y}
onChange={(e) => setEditData({ ...editData, size_y: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-sizeZ" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeZ"
type="number"
value={editData.size_z ?? placement.size_z}
onChange={(e) => setEditData({ ...editData, size_z: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
</div>
</div>
{/* 색상 */}
<div>
<Label htmlFor="edit-color" className="text-xs">
</Label>
<div className="mt-1 flex items-center gap-2">
<input
id="edit-color"
type="color"
value={editData.color ?? placement.color}
onChange={(e) => setEditData({ ...editData, color: e.target.value })}
className="h-8 w-16 cursor-pointer rounded border"
/>
<Input
value={editData.color ?? placement.color}
onChange={(e) => setEditData({ ...editData, color: e.target.value })}
className="h-8 flex-1 text-xs"
/>
</div>
</div>
{/* 메모 */}
<div>
<Label htmlFor="edit-memo" className="text-xs">
</Label>
<Textarea
id="edit-memo"
value={editData.memo ?? placement.memo ?? ""}
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
placeholder="메모를 입력하세요"
rows={3}
className="text-xs"
/>
</div>
{/* 적용 버튼 */}
<Button onClick={handleApply} className="w-full" size="sm">
</Button>
</div>
{/* 배치 해제 */}
<div className="border-t pt-4">
<Button variant="destructive" onClick={() => setIsDeleteDialogOpen(true)} className="w-full" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 삭제 확인 모달 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
&quot;{placement.material_name}&quot; ({placement.quantity} {placement.unit})
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleRemove} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -0,0 +1,192 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Loader2 } from "lucide-react";
import { materialApi } from "@/lib/api/yardLayoutApi";
interface TempMaterial {
id: number;
material_code: string;
material_name: string;
category: string;
unit: string;
default_color: string;
description: string;
}
interface MaterialLibraryProps {
isOpen: boolean;
onClose: () => void;
onSelect: (material: TempMaterial) => void;
}
export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialLibraryProps) {
const [materials, setMaterials] = useState<TempMaterial[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [searchText, setSearchText] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(null);
// 자재 목록 로드
const loadMaterials = async () => {
try {
setIsLoading(true);
const [materialsResponse, categoriesResponse] = await Promise.all([
materialApi.getTempMaterials({
search: searchText || undefined,
category: selectedCategory || undefined,
page: 1,
limit: 50,
}),
materialApi.getCategories(),
]);
if (materialsResponse.success) {
setMaterials(materialsResponse.data);
}
if (categoriesResponse.success) {
setCategories(categoriesResponse.data);
}
} catch (error) {
console.error("자재 목록 조회 실패:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen) {
loadMaterials();
}
}, [isOpen, searchText, selectedCategory]);
// 자재 선택 및 추가
const handleSelectMaterial = () => {
if (selectedMaterial) {
onSelect(selectedMaterial);
setSelectedMaterial(null);
onClose();
}
};
// 모달 닫기
const handleClose = () => {
setSelectedMaterial(null);
setSearchText("");
setSelectedCategory("");
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="자재 코드 또는 이름 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value=""> </option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
{/* 자재 목록 */}
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : materials.length === 0 ? (
<div className="flex h-64 items-center justify-center text-gray-500">
{searchText || selectedCategory ? "검색 결과가 없습니다" : "등록된 자재가 없습니다"}
</div>
) : (
<div className="max-h-96 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow
key={material.id}
className={`cursor-pointer ${
selectedMaterial?.id === material.id ? "bg-blue-50" : "hover:bg-gray-50"
}`}
onClick={() => setSelectedMaterial(material)}
>
<TableCell>
<div className="h-6 w-6 rounded border" style={{ backgroundColor: material.default_color }} />
</TableCell>
<TableCell className="font-medium">{material.material_code}</TableCell>
<TableCell>{material.material_name}</TableCell>
<TableCell>{material.category}</TableCell>
<TableCell>{material.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 선택된 자재 정보 */}
{selectedMaterial && (
<div className="rounded-lg bg-blue-50 p-4">
<div className="mb-2 text-sm font-medium text-blue-900"> </div>
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded border" style={{ backgroundColor: selectedMaterial.default_color }} />
<div className="flex-1">
<div className="font-medium">{selectedMaterial.material_name}</div>
<div className="text-sm text-gray-600">{selectedMaterial.material_code}</div>
</div>
</div>
{selectedMaterial.description && (
<div className="mt-2 text-sm text-gray-600">{selectedMaterial.description}</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
</Button>
<Button onClick={handleSelectMaterial} disabled={!selectedMaterial}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,299 @@
"use client";
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Grid, Box } from "@react-three/drei";
import { Suspense, useRef, useState, useEffect } from "react";
import * as THREE from "three";
interface YardPlacement {
id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
}
interface Yard3DCanvasProps {
placements: YardPlacement[];
selectedPlacementId: number | null;
onPlacementClick: (placement: YardPlacement) => void;
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
}
// 자재 박스 컴포넌트 (드래그 가능)
function MaterialBox({
placement,
isSelected,
onClick,
onDrag,
onDragStart,
onDragEnd,
}: {
placement: YardPlacement;
isSelected: boolean;
onClick: () => void;
onDrag?: (position: { x: number; y: number; z: number }) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const [isDragging, setIsDragging] = useState(false);
const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 });
const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { camera, gl } = useThree();
// 드래그 중이 아닐 때 위치 업데이트
useEffect(() => {
if (!isDragging && meshRef.current) {
meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z);
}
}, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
// 전역 이벤트 리스너 등록
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (isDragging && onDrag && meshRef.current) {
e.preventDefault();
e.stopPropagation();
// 마우스 이동 거리 계산 (픽셀)
const deltaX = e.clientX - mouseStartPos.current.x;
const deltaY = e.clientY - mouseStartPos.current.y;
// 카메라 거리를 고려한 스케일 팩터
const distance = camera.position.distanceTo(meshRef.current.position);
const scaleFactor = distance / 500; // 조정 가능한 값
// 카메라 방향 벡터
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
// 카메라의 우측 벡터 (X축 이동용)
const right = new THREE.Vector3();
right.crossVectors(camera.up, cameraDirection).normalize();
// 실제 3D 공간에서의 이동량 계산
const moveRight = right.multiplyScalar(-deltaX * scaleFactor);
const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z)
.normalize()
.multiplyScalar(deltaY * scaleFactor);
// 최종 위치 계산
const finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
// NaN 검증
if (isNaN(finalX) || isNaN(finalZ)) {
return;
}
// 즉시 mesh 위치 업데이트 (부드러운 드래그)
meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
// 상태 업데이트 (저장용)
onDrag({
x: finalX,
y: dragStartPos.current.y,
z: finalZ,
});
}
};
const handleGlobalMouseUp = () => {
if (isDragging) {
setIsDragging(false);
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
if (onDragEnd) {
onDragEnd();
}
}
};
if (isDragging) {
window.addEventListener("mousemove", handleGlobalMouseMove);
window.addEventListener("mouseup", handleGlobalMouseUp);
return () => {
window.removeEventListener("mousemove", handleGlobalMouseMove);
window.removeEventListener("mouseup", handleGlobalMouseUp);
};
}
}, [isDragging, onDrag, onDragEnd, camera, isSelected, gl.domElement]);
const handlePointerDown = (e: any) => {
e.stopPropagation();
// 뷰어 모드(onDrag 없음)에서는 클릭만 처리
if (!onDrag) {
return;
}
// 편집 모드에서 선택되었고 드래그 가능한 경우
if (isSelected && meshRef.current) {
// 드래그 시작 시점의 자재 위치 저장 (숫자로 변환)
dragStartPos.current = {
x: Number(placement.position_x),
y: Number(placement.position_y),
z: Number(placement.position_z),
};
// 마우스 시작 위치 저장
mouseStartPos.current = {
x: e.clientX,
y: e.clientY,
};
setIsDragging(true);
gl.domElement.style.cursor = "grabbing";
if (onDragStart) {
onDragStart();
}
}
};
return (
<Box
ref={meshRef}
position={[placement.position_x, placement.position_y, placement.position_z]}
args={[placement.size_x, placement.size_y, placement.size_z]}
onClick={(e) => {
e.stopPropagation();
e.nativeEvent?.stopPropagation();
e.nativeEvent?.stopImmediatePropagation();
console.log("3D Box clicked:", placement.material_name);
onClick();
}}
onPointerDown={handlePointerDown}
onPointerOver={() => {
// 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서
if (onDrag) {
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
} else {
gl.domElement.style.cursor = "pointer";
}
}}
onPointerOut={() => {
if (!isDragging) {
gl.domElement.style.cursor = "default";
}
}}
>
<meshStandardMaterial
color={placement.color}
opacity={isSelected ? 1 : 0.8}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
/>
</Box>
);
}
// 3D 씬 컴포넌트
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef<any>(null);
return (
<>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
{/* 바닥 그리드 */}
<Grid
args={[100, 100]}
cellSize={5}
cellThickness={0.5}
cellColor="#6b7280"
sectionSize={10}
sectionThickness={1}
sectionColor="#374151"
fadeDistance={200}
fadeStrength={1}
followCamera={false}
infiniteGrid={true}
/>
{/* 자재 박스들 */}
{placements.map((placement) => (
<MaterialBox
key={placement.id}
placement={placement}
isSelected={selectedPlacementId === placement.id}
onClick={() => onPlacementClick(placement)}
onDrag={onPlacementDrag ? (position) => onPlacementDrag(placement.id, position) : undefined}
onDragStart={() => {
setIsDraggingAny(true);
if (orbitControlsRef.current) {
orbitControlsRef.current.enabled = false;
}
}}
onDragEnd={() => {
setIsDraggingAny(false);
if (orbitControlsRef.current) {
orbitControlsRef.current.enabled = true;
}
}}
/>
))}
{/* 카메라 컨트롤 */}
<OrbitControls
ref={orbitControlsRef}
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={10}
maxDistance={200}
maxPolarAngle={Math.PI / 2}
enabled={!isDraggingAny}
/>
</>
);
}
export default function Yard3DCanvas({
placements,
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
}: Yard3DCanvasProps) {
const handleCanvasClick = (e: any) => {
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
// e.target이 canvas 엘리먼트인 경우
if (e.target.tagName === "CANVAS") {
onPlacementClick(null as any);
}
};
return (
<div className="h-full w-full bg-gray-900" onClick={handleCanvasClick}>
<Canvas
camera={{
position: [50, 30, 50],
fov: 50,
}}
shadows
>
<Suspense fallback={null}>
<Scene
placements={placements}
selectedPlacementId={selectedPlacementId}
onPlacementClick={onPlacementClick}
onPlacementDrag={onPlacementDrag}
/>
</Suspense>
</Canvas>
</div>
);
}

View File

@ -0,0 +1,161 @@
"use client";
import { useState, useEffect } from "react";
import Yard3DCanvas from "./Yard3DCanvas";
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
import { Loader2 } from "lucide-react";
interface YardPlacement {
id: number;
yard_layout_id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
status?: string;
memo?: string;
}
interface Yard3DViewerProps {
layoutId: number;
}
export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
const [placements, setPlacements] = useState<YardPlacement[]>([]);
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
const [layoutName, setLayoutName] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 선택 변경 로그
const handlePlacementClick = (placement: YardPlacement | null) => {
console.log("Yard3DViewer - Placement clicked:", placement?.material_name);
setSelectedPlacement(placement);
};
// 선택 상태 변경 감지
useEffect(() => {
console.log("selectedPlacement changed:", selectedPlacement?.material_name);
}, [selectedPlacement]);
// 야드 레이아웃 및 배치 데이터 로드
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
setError(null);
// 야드 레이아웃 정보 조회
const layoutResponse = await yardLayoutApi.getLayoutById(layoutId);
if (layoutResponse.success) {
setLayoutName(layoutResponse.data.name);
}
// 배치 데이터 조회
const placementsResponse = await yardLayoutApi.getPlacementsByLayoutId(layoutId);
if (placementsResponse.success) {
setPlacements(placementsResponse.data);
} else {
setError("배치 데이터를 불러올 수 없습니다.");
}
} catch (err) {
console.error("데이터 로드 실패:", err);
setError("데이터를 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
loadData();
}, [layoutId]);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
<div className="mt-2 text-sm text-gray-600">3D ...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl"></div>
<div className="text-sm font-medium text-gray-600">{error}</div>
</div>
</div>
);
}
if (placements.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl">📦</div>
<div className="text-sm font-medium text-gray-600"> </div>
</div>
</div>
);
}
return (
<div className="relative h-full w-full">
{/* 3D 캔버스 */}
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={handlePlacementClick}
/>
{/* 야드 이름 (좌측 상단) */}
{layoutName && (
<div className="absolute top-4 left-4 z-50 rounded-lg border border-gray-300 bg-white px-4 py-2 shadow-lg">
<h2 className="text-base font-bold text-gray-900">{layoutName}</h2>
</div>
)}
{/* 선택된 자재 정보 패널 (우측 상단) */}
{selectedPlacement && (
<div className="absolute top-4 right-4 z-50 w-64 rounded-lg border border-gray-300 bg-white p-4 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800"> </h3>
<button
onClick={() => {
setSelectedPlacement(null);
}}
className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
</button>
</div>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-gray-500"></label>
<div className="mt-1 text-sm font-semibold text-gray-900">{selectedPlacement.material_name}</div>
</div>
<div>
<label className="text-xs font-medium text-gray-500"></label>
<div className="mt-1 text-sm font-semibold text-gray-900">
{selectedPlacement.quantity} {selectedPlacement.unit}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,461 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Loader2, X } from "lucide-react";
import { yardLayoutApi, materialApi } from "@/lib/api/yardLayoutApi";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import dynamic from "next/dynamic";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center bg-gray-900">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
),
});
interface TempMaterial {
id: number;
material_code: string;
material_name: string;
category: string;
unit: string;
default_color: string;
description: string;
}
interface YardLayout {
id: number;
name: string;
description: string;
placement_count?: number;
updated_at: string;
}
interface YardPlacement {
id: number;
yard_layout_id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
memo?: string;
}
interface YardEditorProps {
layout: YardLayout;
onBack: () => void;
}
export default function YardEditor({ layout, onBack }: YardEditorProps) {
const [placements, setPlacements] = useState<YardPlacement[]>([]);
const [materials, setMaterials] = useState<TempMaterial[]>([]);
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
// 배치 목록 & 자재 목록 로드
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
const [placementsRes, materialsRes] = await Promise.all([
yardLayoutApi.getPlacementsByLayoutId(layout.id),
materialApi.getTempMaterials({ limit: 100 }),
]);
if (placementsRes.success) {
setPlacements(placementsRes.data);
}
if (materialsRes.success) {
setMaterials(materialsRes.data);
}
} catch (error) {
console.error("데이터 로드 실패:", error);
} finally {
setIsLoading(false);
}
};
loadData();
}, [layout.id]);
// 자재 클릭 → 배치 추가
const handleMaterialClick = async (material: TempMaterial) => {
// 이미 배치되었는지 확인
const alreadyPlaced = placements.find((p) => p.material_code === material.material_code);
if (alreadyPlaced) {
alert("이미 배치된 자재입니다.");
return;
}
setSelectedMaterial(material);
// 기본 위치에 배치
const placementData = {
external_material_id: `TEMP-${material.id}`,
material_code: material.material_code,
material_name: material.material_name,
quantity: 1,
unit: material.unit,
position_x: 0,
position_y: 0,
position_z: 0,
size_x: 5,
size_y: 5,
size_z: 5,
color: material.default_color,
};
try {
const response = await yardLayoutApi.addMaterialPlacement(layout.id, placementData);
if (response.success) {
setPlacements((prev) => [...prev, response.data]);
setSelectedPlacement(response.data);
setSelectedMaterial(null);
}
} catch (error: any) {
console.error("자재 배치 실패:", error);
alert("자재 배치에 실패했습니다.");
}
};
// 자재 드래그 (3D 캔버스에서)
const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => {
const updatedPosition = {
position_x: Math.round(position.x * 2) / 2,
position_y: position.y,
position_z: Math.round(position.z * 2) / 2,
};
setPlacements((prev) =>
prev.map((p) =>
p.id === id
? {
...p,
...updatedPosition,
}
: p,
),
);
// 선택된 자재도 업데이트
if (selectedPlacement?.id === id) {
setSelectedPlacement((prev) =>
prev
? {
...prev,
...updatedPosition,
}
: null,
);
}
};
// 자재 배치 해제
const handlePlacementRemove = async (id: number) => {
try {
const response = await yardLayoutApi.removePlacement(id);
if (response.success) {
setPlacements((prev) => prev.filter((p) => p.id !== id));
setSelectedPlacement(null);
}
} catch (error) {
console.error("배치 해제 실패:", error);
alert("배치 해제에 실패했습니다.");
}
};
// 위치/크기/색상 업데이트
const handlePlacementUpdate = (id: number, updates: Partial<YardPlacement>) => {
setPlacements((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p)));
};
// 저장
const handleSave = async () => {
setIsSaving(true);
try {
const response = await yardLayoutApi.batchUpdatePlacements(
layout.id,
placements.map((p) => ({
id: p.id,
position_x: p.position_x,
position_y: p.position_y,
position_z: p.position_z,
size_x: p.size_x,
size_y: p.size_y,
size_z: p.size_z,
color: p.color,
})),
);
if (response.success) {
alert("저장되었습니다");
}
} catch (error) {
console.error("저장 실패:", error);
alert("저장에 실패했습니다");
} finally {
setIsSaving(false);
}
};
// 필터링된 자재 목록
const filteredMaterials = materials.filter(
(m) =>
m.material_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.material_code.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="flex h-full flex-col bg-white">
{/* 상단 툴바 */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<div>
<h2 className="text-lg font-semibold">{layout.name}</h2>
{layout.description && <p className="text-sm text-gray-500">{layout.description}</p>}
</div>
</div>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{/* 메인 컨텐츠 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 3D 캔버스 */}
<div className="flex-1">
{isLoading ? (
<div className="flex h-full items-center justify-center bg-gray-50">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={setSelectedPlacement}
onPlacementDrag={handlePlacementDrag}
/>
)}
</div>
{/* 우측: 자재 목록 또는 편집 패널 */}
<div className="w-80 border-l bg-white">
{selectedPlacement ? (
// 선택된 자재 편집 패널
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="ghost" size="sm" onClick={() => setSelectedPlacement(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="space-y-4">
{/* 읽기 전용 정보 */}
<div>
<Label className="text-xs text-gray-500"> </Label>
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_code}</div>
</div>
<div>
<Label className="text-xs text-gray-500"></Label>
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_name}</div>
</div>
<div>
<Label className="text-xs text-gray-500"> ( )</Label>
<div className="mt-1 text-sm">
{selectedPlacement.quantity} {selectedPlacement.unit}
</div>
</div>
{/* 편집 가능 정보 */}
<div className="space-y-3 border-t pt-4">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs">X</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_x}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_x: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs">Y</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_y}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_y: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs">Z</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_z}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_z: parseFloat(e.target.value),
})
}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_x}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_x: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_y}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_y: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_z}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_z: parseFloat(e.target.value),
})
}
/>
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="color"
value={selectedPlacement.color}
onChange={(e) => handlePlacementUpdate(selectedPlacement.id, { color: e.target.value })}
/>
</div>
</div>
<Button
variant="destructive"
size="sm"
className="w-full"
onClick={() => handlePlacementRemove(selectedPlacement.id)}
>
</Button>
</div>
</div>
</div>
) : (
// 자재 목록
<div className="flex h-full flex-col">
<div className="border-b p-4">
<h3 className="mb-2 text-sm font-semibold"> </h3>
<Input
placeholder="자재 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="text-sm"
/>
</div>
<div className="flex-1 overflow-auto">
{filteredMaterials.length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
</div>
) : (
<div className="p-2">
{filteredMaterials.map((material) => {
const isPlaced = placements.some((p) => p.material_code === material.material_code);
return (
<button
key={material.id}
onClick={() => !isPlaced && handleMaterialClick(material)}
disabled={isPlaced}
className={`mb-2 w-full rounded-lg border p-3 text-left transition-all ${
isPlaced
? "cursor-not-allowed border-gray-200 bg-gray-50 opacity-50"
: "cursor-pointer border-gray-200 bg-white hover:border-blue-500 hover:shadow-sm"
}`}
>
<div className="mb-1 text-sm font-medium text-gray-900">{material.material_name}</div>
<div className="text-xs text-gray-500">{material.material_code}</div>
<div className="mt-1 text-xs text-gray-400">{material.category}</div>
{isPlaced && <div className="mt-1 text-xs text-blue-600"></div>}
</button>
);
})}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
interface YardLayoutCreateModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (name: string, description: string) => Promise<void>;
}
export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: YardLayoutCreateModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState("");
// 생성 실행
const handleCreate = async () => {
if (!name.trim()) {
setError("야드 이름을 입력하세요");
return;
}
setIsCreating(true);
setError("");
try {
await onCreate(name.trim(), description.trim());
setName("");
setDescription("");
} catch (error: any) {
console.error("야드 생성 실패:", error);
setError(error.message || "야드 생성에 실패했습니다");
} finally {
setIsCreating(false);
}
};
// 모달 닫기
const handleClose = () => {
if (isCreating) return;
setName("");
setDescription("");
setError("");
onClose();
};
// Enter 키 처리
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCreate();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 야드 이름 */}
<div className="space-y-2">
<Label htmlFor="yard-name">
<span className="text-red-500">*</span>
</Label>
<Input
id="yard-name"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
onKeyDown={handleKeyDown}
placeholder="예: A구역, 1번 야드"
disabled={isCreating}
autoFocus
/>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="yard-description"></Label>
<Textarea
id="yard-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="야드에 대한 설명을 입력하세요 (선택사항)"
rows={3}
disabled={isCreating}
/>
</div>
{/* 에러 메시지 */}
{error && <div className="rounded-md bg-red-50 p-3 text-sm text-red-600">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
</Button>
<Button onClick={handleCreate} disabled={!name.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,277 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, MoreVertical, Loader2 } from "lucide-react";
interface YardLayout {
id: number;
name: string;
description: string;
placement_count: number;
updated_at: string;
}
interface YardLayoutListProps {
layouts: YardLayout[];
isLoading: boolean;
onSelect: (layout: YardLayout) => void;
onDelete: (id: number) => Promise<void>;
onDuplicate: (id: number, newName: string) => Promise<void>;
}
export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete, onDuplicate }: YardLayoutListProps) {
const [searchText, setSearchText] = useState("");
const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
const [deleteTarget, setDeleteTarget] = useState<YardLayout | null>(null);
const [duplicateTarget, setDuplicateTarget] = useState<YardLayout | null>(null);
const [duplicateName, setDuplicateName] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
// 검색 필터링
const filteredLayouts = layouts.filter((layout) => {
if (!searchText) return true;
return (
layout.name.toLowerCase().includes(searchText.toLowerCase()) ||
layout.description?.toLowerCase().includes(searchText.toLowerCase())
);
});
// 정렬
const sortedLayouts = [...filteredLayouts].sort((a, b) => {
if (sortOrder === "recent") {
return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
} else {
return a.name.localeCompare(b.name);
}
});
// 날짜 포맷팅
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
// 삭제 확인
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
await onDelete(deleteTarget.id);
setDeleteTarget(null);
} catch (error) {
console.error("삭제 실패:", error);
} finally {
setIsDeleting(false);
}
};
// 복제 실행
const handleDuplicateConfirm = async () => {
if (!duplicateTarget || !duplicateName.trim()) return;
setIsDuplicating(true);
try {
await onDuplicate(duplicateTarget.id, duplicateName);
setDuplicateTarget(null);
setDuplicateName("");
} catch (error) {
console.error("복제 실패:", error);
} finally {
setIsDuplicating(false);
}
};
// 복제 모달 열기
const handleDuplicateClick = (layout: YardLayout) => {
setDuplicateTarget(layout);
setDuplicateName(`${layout.name} (복사본)`);
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="flex h-full flex-col space-y-4">
{/* 검색 및 정렬 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="야드 이름 또는 설명 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as "recent" | "name")}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value="recent"> </option>
<option value="name"></option>
</select>
</div>
{/* 테이블 */}
{sortedLayouts.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<div className="text-center text-gray-500">
{searchText ? "검색 결과가 없습니다" : "등록된 야드가 없습니다"}
</div>
</div>
) : (
<div className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead> </TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedLayouts.map((layout) => (
<TableRow key={layout.id} className="cursor-pointer hover:bg-gray-50" onClick={() => onSelect(layout)}>
<TableCell className="font-medium">{layout.name}</TableCell>
<TableCell className="text-gray-600">{layout.description || "-"}</TableCell>
<TableCell className="text-center">{layout.placement_count}</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(layout.updated_at)}</TableCell>
<TableCell className="text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onSelect(layout)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicateClick(layout)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteTarget(layout)} className="text-red-600">
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 총 개수 */}
<div className="text-sm text-gray-500"> {sortedLayouts.length}</div>
{/* 삭제 확인 모달 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.name}&quot; ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 복제 모달 */}
<Dialog open={!!duplicateTarget} onOpenChange={() => setDuplicateTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="duplicate-name"> </Label>
<Input
id="duplicate-name"
value={duplicateName}
onChange={(e) => setDuplicateName(e.target.value)}
placeholder="야드 이름을 입력하세요"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDuplicateTarget(null)} disabled={isDuplicating}>
</Button>
<Button onClick={handleDuplicateConfirm} disabled={!duplicateName.trim() || isDuplicating}>
{isDuplicating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"복제"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -3,6 +3,7 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types";
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
import { DashboardProvider } from "@/contexts/DashboardContext";
import { RESOLUTIONS, Resolution } from "@/components/admin/dashboard/ResolutionSelector";
import dynamic from "next/dynamic";
@ -38,13 +39,9 @@ const ListWidget = dynamic(
{ ssr: false },
);
const Warehouse3DWidget = dynamic(
() =>
import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({
default: mod.Warehouse3DWidget,
})),
{ ssr: false },
);
const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), {
ssr: false,
});
/**
* - DashboardSidebar의 subtype
@ -84,8 +81,8 @@ function renderWidget(element: DashboardElement) {
case "list":
return <ListWidget element={element} />;
case "warehouse-3d":
return <Warehouse3DWidget element={element} />;
case "yard-management-3d":
return <YardManagement3DWidget isEditMode={false} config={element.yardConfig} />;
// === 차량 관련 (추가 위젯) ===
case "vehicle-status":
@ -122,7 +119,9 @@ function renderWidget(element: DashboardElement) {
interface DashboardViewerProps {
elements: DashboardElement[];
dashboardId?: string;
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
backgroundColor?: string; // 배경색
resolution?: string; // 대시보드 해상도
}
@ -132,7 +131,13 @@ interface DashboardViewerProps {
* -
* - ( , )
*/
export function DashboardViewer({ elements, refreshInterval, resolution = "fhd" }: DashboardViewerProps) {
export function DashboardViewer({
elements,
dashboardId,
refreshInterval,
backgroundColor = "#f9fafb",
resolution = "fhd",
}: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
@ -250,28 +255,32 @@ export function DashboardViewer({ elements, refreshInterval, resolution = "fhd"
}
return (
<div className="flex h-full items-start justify-center overflow-auto bg-gray-100 p-8">
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
<div
className="relative overflow-hidden rounded-lg"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
}}
>
{/* 대시보드 요소들 */}
{elements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
/>
))}
<DashboardProvider>
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
<div className="flex h-full items-start justify-center bg-gray-100 p-8">
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
<div
className="relative overflow-hidden rounded-lg"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
backgroundColor: backgroundColor,
}}
>
{/* 대시보드 요소들 */}
{elements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
/>
))}
</div>
</div>
</div>
</DashboardProvider>
);
}
@ -305,21 +314,21 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3>
{/* 새로고침 버튼 (호버 시에만 표시) */}
{isHovered && (
<button
onClick={onRefresh}
disabled={isLoading}
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
</button>
)}
{/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */}
<button
onClick={onRefresh}
disabled={isLoading}
className={`hover:text-muted-foreground text-gray-400 transition-opacity disabled:opacity-50 ${
isHovered ? "opacity-100" : "opacity-0"
}`}
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
</button>
</div>
)}

View File

@ -161,7 +161,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-gray-800">🔔 {element?.customTitle || "예약 요청 알림"}</h3>
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "예약 요청 알림"}</h3>
{newCount > 0 && (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{newCount}

View File

@ -7,7 +7,7 @@
* -
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { DashboardElement } from '@/components/admin/dashboard/types';
@ -117,11 +117,62 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
setDisplay(String(value / 100));
};
// 키보드 입력 처리
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key;
// 숫자 키 (0-9)
if (/^[0-9]$/.test(key)) {
event.preventDefault();
handleNumber(key);
}
// 연산자 키
else if (key === '+' || key === '-' || key === '*' || key === '/') {
event.preventDefault();
handleOperation(key);
}
// 소수점
else if (key === '.') {
event.preventDefault();
handleDecimal();
}
// Enter 또는 = (계산)
else if (key === 'Enter' || key === '=') {
event.preventDefault();
handleEquals();
}
// Escape 또는 c (초기화)
else if (key === 'Escape' || key.toLowerCase() === 'c') {
event.preventDefault();
handleClear();
}
// Backspace (지우기)
else if (key === 'Backspace') {
event.preventDefault();
handleBackspace();
}
// % (퍼센트)
else if (key === '%') {
event.preventDefault();
handlePercent();
}
};
// 이벤트 리스너 등록
window.addEventListener('keydown', handleKeyDown);
// 클린업
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [display, previousValue, operation, waitingForOperand]);
return (
<div className={`h-full w-full p-3 bg-gradient-to-br from-slate-50 to-gray-100 ${className}`}>
<div className="h-full flex flex-col gap-2">
{/* 제목 */}
<h3 className="text-base font-semibold text-gray-900 text-center">🧮 {element?.customTitle || "계산기"}</h3>
<h3 className="text-base font-semibold text-gray-900 text-center">{element?.customTitle || "계산기"}</h3>
{/* 디스플레이 */}
<div className="bg-white border-2 border-gray-200 rounded-lg p-4 shadow-inner min-h-[80px]">

View File

@ -150,7 +150,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground"> /</h3>
<h3 className="text-lg font-semibold text-foreground"> /</h3>
<button
onClick={loadData}
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"

View File

@ -130,7 +130,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
<div className="flex h-full flex-col overflow-hidden bg-white p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800">📅 </h3>
<h3 className="text-lg font-semibold text-gray-800"> </h3>
<button
onClick={loadData}
className="rounded-full p-1 text-gray-500 hover:bg-gray-100"

View File

@ -132,7 +132,7 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
{/* 헤더 */}
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800">📂 {element?.customTitle || "문서 관리"}</h3>
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "문서 관리"}</h3>
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90">
+
</button>

View File

@ -135,11 +135,11 @@ export default function ExchangeWidget({
const hasError = error || !exchangeRate;
return (
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-4">
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-4 @container">
{/* 헤더 */}
<div className="flex items-center justify-between mb-3">
<div className="flex-1">
<h3 className="text-base font-semibold text-gray-900 mb-1">💱 {element?.customTitle || "환율"}</h3>
<h3 className="text-base font-semibold text-gray-900 mb-1">{element?.customTitle || "환율"}</h3>
<p className="text-xs text-gray-500">
{lastUpdated
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
@ -160,10 +160,10 @@ export default function ExchangeWidget({
</Button>
</div>
{/* 통화 선택 */}
<div className="flex items-center gap-2 mb-3">
{/* 통화 선택 - 반응형 (좁을 때 세로 배치) */}
<div className="flex @[300px]:flex-row flex-col items-center gap-2 mb-3">
<Select value={base} onValueChange={setBase}>
<SelectTrigger className="flex-1 bg-white h-8 text-xs">
<SelectTrigger className="w-full @[300px]:flex-1 bg-white h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -179,13 +179,13 @@ export default function ExchangeWidget({
variant="ghost"
size="sm"
onClick={handleSwap}
className="h-8 w-8 p-0 rounded-full hover:bg-white"
className="h-8 w-8 p-0 rounded-full hover:bg-white @[300px]:rotate-0 rotate-90"
>
<ArrowRightLeft className="h-3 w-3" />
</Button>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="flex-1 bg-white h-8 text-xs">
<SelectTrigger className="w-full @[300px]:flex-1 bg-white h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@ -158,7 +158,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
{/* 헤더 */}
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900">📍 {displayTitle}</h3>
<h3 className="text-sm font-bold text-gray-900">{displayTitle}</h3>
{element?.dataSource?.query ? (
<p className="text-xs text-gray-500"> {markers.length.toLocaleString()} </p>
) : (

View File

@ -1,8 +1,9 @@
"use client";
import React, { useState, useEffect } from "react";
import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react";
import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown, Calendar as CalendarIcon } from "lucide-react";
import { DashboardElement } from "@/components/admin/dashboard/types";
import { useDashboard } from "@/contexts/DashboardContext";
interface TodoItem {
id: string;
@ -33,6 +34,9 @@ interface TodoWidgetProps {
}
export default function TodoWidget({ element }: TodoWidgetProps) {
// Context에서 선택된 날짜 가져오기
const { selectedDate } = useDashboard();
const [todos, setTodos] = useState<TodoItem[]>([]);
const [stats, setStats] = useState<TodoStats | null>(null);
const [loading, setLoading] = useState(true);
@ -51,22 +55,85 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
fetchTodos();
const interval = setInterval(fetchTodos, 30000); // 30초마다 갱신
return () => clearInterval(interval);
}, [filter]);
}, [filter, selectedDate]); // selectedDate도 의존성에 추가
const fetchTodos = async () => {
try {
const token = localStorage.getItem("authToken");
const filterParam = filter !== "all" ? `?status=${filter}` : "";
const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const userLang = localStorage.getItem("userLang") || "KR";
// 외부 DB 조회 (dataSource가 설정된 경우)
if (element?.dataSource?.query) {
// console.log("🔍 TodoWidget - 외부 DB 조회 시작");
// console.log("📝 Query:", element.dataSource.query);
// console.log("🔗 ConnectionId:", element.dataSource.externalConnectionId);
// console.log("🔗 ConnectionType:", element.dataSource.connectionType);
// 현재 DB vs 외부 DB 분기
const apiUrl = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId
? `http://localhost:9771/api/external-db/query?userLang=${userLang}`
: `http://localhost:9771/api/dashboards/execute-query?userLang=${userLang}`;
const requestBody = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId
? {
connectionId: parseInt(element.dataSource.externalConnectionId),
query: element.dataSource.query,
}
: {
query: element.dataSource.query,
};
if (response.ok) {
const result = await response.json();
setTodos(result.data || []);
setStats(result.stats);
// console.log("🌐 API URL:", apiUrl);
// console.log("📦 Request Body:", requestBody);
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
});
// console.log("📡 Response status:", response.status);
if (response.ok) {
const result = await response.json();
// console.log("✅ API 응답:", result);
// console.log("📦 result.data:", result.data);
// console.log("📦 result.data.rows:", result.data?.rows);
// API 응답 형식에 따라 데이터 추출
const rows = result.data?.rows || result.data || [];
// console.log("📊 추출된 rows:", rows);
const externalTodos = mapExternalDataToTodos(rows);
// console.log("📋 변환된 Todos:", externalTodos);
// console.log("📋 변환된 Todos 개수:", externalTodos.length);
setTodos(externalTodos);
setStats(calculateStatsFromTodos(externalTodos));
// console.log("✅ setTodos, setStats 호출 완료!");
} else {
const errorText = await response.text();
// console.error("❌ API 오류:", errorText);
}
}
// 내장 API 조회 (기본)
else {
const filterParam = filter !== "all" ? `?status=${filter}` : "";
const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const result = await response.json();
setTodos(result.data || []);
setStats(result.stats);
}
}
} catch (error) {
// console.error("To-Do 로딩 오류:", error);
@ -75,8 +142,48 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
}
};
// 외부 DB 데이터를 TodoItem 형식으로 변환
const mapExternalDataToTodos = (data: any[]): TodoItem[] => {
return data.map((row, index) => ({
id: row.id || `todo-${index}`,
title: row.title || row.task || row.name || "제목 없음",
description: row.description || row.desc || row.content,
priority: row.priority || "normal",
status: row.status || "pending",
assignedTo: row.assigned_to || row.assignedTo || row.user,
dueDate: row.due_date || row.dueDate || row.deadline,
createdAt: row.created_at || row.createdAt || new Date().toISOString(),
updatedAt: row.updated_at || row.updatedAt || new Date().toISOString(),
completedAt: row.completed_at || row.completedAt,
isUrgent: row.is_urgent || row.isUrgent || row.urgent || false,
order: row.display_order || row.order || index,
}));
};
// Todo 배열로부터 통계 계산
const calculateStatsFromTodos = (todoList: TodoItem[]): TodoStats => {
return {
total: todoList.length,
pending: todoList.filter((t) => t.status === "pending").length,
inProgress: todoList.filter((t) => t.status === "in_progress").length,
completed: todoList.filter((t) => t.status === "completed").length,
urgent: todoList.filter((t) => t.isUrgent).length,
overdue: todoList.filter((t) => {
if (!t.dueDate) return false;
return new Date(t.dueDate) < new Date() && t.status !== "completed";
}).length,
};
};
// 외부 DB 조회 여부 확인
const isExternalData = !!element?.dataSource?.query;
const handleAddTodo = async () => {
if (!newTodo.title.trim()) return;
if (isExternalData) {
alert("외부 데이터베이스 조회 모드에서는 추가할 수 없습니다.");
return;
}
try {
const token = localStorage.getItem("authToken");
@ -185,6 +292,27 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
return "⚠️ 오늘 마감";
};
// 선택된 날짜로 필터링
const filteredTodos = selectedDate
? todos.filter((todo) => {
if (!todo.dueDate) return false;
const todoDate = new Date(todo.dueDate);
return (
todoDate.getFullYear() === selectedDate.getFullYear() &&
todoDate.getMonth() === selectedDate.getMonth() &&
todoDate.getDate() === selectedDate.getDate()
);
})
: todos;
const formatSelectedDate = () => {
if (!selectedDate) return null;
const year = selectedDate.getFullYear();
const month = selectedDate.getMonth() + 1;
const day = selectedDate.getDate();
return `${year}${month}${day}`;
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
@ -195,59 +323,71 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
return (
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
{/* 헤더 */}
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800"> {element?.customTitle || "To-Do / 긴급 지시"}</h3>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* 통계 */}
{stats && (
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
<div className="font-bold text-blue-700">{stats.pending}</div>
<div className="text-blue-600"></div>
</div>
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
<div className="font-bold text-amber-700">{stats.inProgress}</div>
<div className="text-amber-600"></div>
</div>
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
<div className="font-bold text-red-700">{stats.urgent}</div>
<div className="text-red-600"></div>
</div>
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
<div className="font-bold text-rose-700">{stats.overdue}</div>
<div className="text-rose-600"></div>
</div>
{/* 제목 - 항상 표시 */}
<div className="border-b border-gray-200 bg-white px-4 py-2">
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "To-Do / 긴급 지시"}</h3>
{selectedDate && (
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
<CalendarIcon className="h-3 w-3" />
<span className="font-semibold">{formatSelectedDate()} </span>
</div>
)}
{/* 필터 */}
<div className="mt-3 flex gap-2">
{(["all", "pending", "in_progress", "completed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
filter === f
? "bg-primary text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
</button>
))}
</div>
</div>
{/* 헤더 (추가 버튼, 통계, 필터) - showHeader가 false일 때만 숨김 */}
{element?.showHeader !== false && (
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-end">
<button
onClick={() => setShowAddForm(!showAddForm)}
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* 통계 */}
{stats && (
<div className="grid grid-cols-4 gap-2 text-xs mb-3">
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
<div className="font-bold text-blue-700">{stats.pending}</div>
<div className="text-blue-600"></div>
</div>
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
<div className="font-bold text-amber-700">{stats.inProgress}</div>
<div className="text-amber-600"></div>
</div>
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
<div className="font-bold text-red-700">{stats.urgent}</div>
<div className="text-red-600"></div>
</div>
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
<div className="font-bold text-rose-700">{stats.overdue}</div>
<div className="text-rose-600"></div>
</div>
</div>
)}
{/* 필터 */}
<div className="flex gap-2">
{(["all", "pending", "in_progress", "completed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
filter === f
? "bg-primary text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
</button>
))}
</div>
</div>
)}
{/* 추가 폼 */}
{showAddForm && (
<div className="border-b border-gray-200 bg-white p-4">
@ -315,16 +455,16 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
{/* To-Do 리스트 */}
<div className="flex-1 overflow-y-auto p-4">
{todos.length === 0 ? (
{filteredTodos.length === 0 ? (
<div className="flex h-full items-center justify-center text-gray-400">
<div className="text-center">
<div className="mb-2 text-4xl">📝</div>
<div> </div>
<div>{selectedDate ? `${formatSelectedDate()} 할 일이 없습니다` : "할 일이 없습니다"}</div>
</div>
</div>
) : (
<div className="space-y-2">
{todos.map((todo) => (
{filteredTodos.map((todo) => (
<div
key={todo.id}
className={`group relative rounded-lg border-2 bg-white p-3 shadow-sm transition-all hover:shadow-md ${

View File

@ -97,7 +97,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900">📋 </h3>
<h3 className="text-lg font-bold text-gray-900"> </h3>
<p className="text-xs text-gray-500"> : {lastUpdate.toLocaleTimeString("ko-KR")}</p>
</div>
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">

View File

@ -280,9 +280,12 @@ export default function WeatherWidget({
if (loading && !weather) {
return (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
<div className="flex flex-col items-center gap-2">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-sm text-gray-600"> ...</p>
<div className="text-center">
<p className="text-sm font-semibold text-gray-800 mb-1"> API ...</p>
<p className="text-xs text-gray-500"> </p>
</div>
</div>
</div>
);
@ -290,10 +293,27 @@ export default function WeatherWidget({
// 에러 상태
if (error || !weather) {
const isTestMode = error?.includes('API 키가 설정되지 않았습니다');
return (
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
<div className={`flex h-full flex-col items-center justify-center rounded-lg border p-6 ${
isTestMode
? 'bg-gradient-to-br from-yellow-50 to-orange-50'
: 'bg-gradient-to-br from-red-50 to-orange-50'
}`}>
<Cloud className="h-12 w-12 text-gray-400 mb-2" />
<p className="text-sm text-gray-600 text-center mb-3">{error || '날씨 정보를 불러올 수 없습니다.'}</p>
<div className="text-center mb-3">
<p className="text-sm font-semibold text-gray-800 mb-1">
{isTestMode ? '⚠️ 테스트 모드' : '❌ 연결 실패'}
</p>
<p className="text-xs text-gray-600">
{error || '날씨 정보를 불러올 수 없습니다.'}
</p>
{isTestMode && (
<p className="text-xs text-yellow-700 mt-2">
</p>
)}
</div>
<Button
variant="outline"
size="sm"

View File

@ -457,7 +457,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</aside>
{/* 가운데 컨텐츠 영역 - overflow 문제 해결 */}
<main className="min-w-0 flex-1 overflow-auto bg-white">{children}</main>
<main className="min-w-0 flex-1 bg-white">{children}</main>
</div>
{/* 프로필 수정 모달 */}

View File

@ -0,0 +1,34 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from "react";
/**
* Context
* - /
*/
interface DashboardContextType {
selectedDate: Date | null;
setSelectedDate: (date: Date | null) => void;
}
const DashboardContext = createContext<DashboardContextType | undefined>(undefined);
export function DashboardProvider({ children }: { children: ReactNode }) {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
return (
<DashboardContext.Provider value={{ selectedDate, setSelectedDate }}>
{children}
</DashboardContext.Provider>
);
}
export function useDashboard() {
const context = useContext(DashboardContext);
if (context === undefined) {
throw new Error("useDashboard must be used within a DashboardProvider");
}
return context;
}

View File

@ -0,0 +1,84 @@
import { apiCall } from "./client";
// 야드 레이아웃 관리 API
export const yardLayoutApi = {
// 모든 야드 레이아웃 목록 조회
async getAllLayouts() {
return apiCall("GET", "/yard-layouts");
},
// 특정 야드 레이아웃 상세 조회
async getLayoutById(id: number) {
return apiCall("GET", `/yard-layouts/${id}`);
},
// 새 야드 레이아웃 생성
async createLayout(data: { name: string; description?: string }) {
return apiCall("POST", "/yard-layouts", data);
},
// 야드 레이아웃 수정
async updateLayout(id: number, data: { name?: string; description?: string }) {
return apiCall("PUT", `/yard-layouts/${id}`, data);
},
// 야드 레이아웃 삭제
async deleteLayout(id: number) {
return apiCall("DELETE", `/yard-layouts/${id}`);
},
// 야드 레이아웃 복제
async duplicateLayout(id: number, name: string) {
return apiCall("POST", `/yard-layouts/${id}/duplicate`, { name });
},
// 특정 야드의 배치 자재 목록 조회
async getPlacementsByLayoutId(layoutId: number) {
return apiCall("GET", `/yard-layouts/${layoutId}/placements`);
},
// 야드에 자재 배치 추가
async addMaterialPlacement(layoutId: number, data: any) {
return apiCall("POST", `/yard-layouts/${layoutId}/placements`, data);
},
// 배치 정보 수정
async updatePlacement(placementId: number, data: any) {
return apiCall("PUT", `/yard-layouts/placements/${placementId}`, data);
},
// 배치 해제
async removePlacement(placementId: number) {
return apiCall("DELETE", `/yard-layouts/placements/${placementId}`);
},
// 여러 배치 일괄 업데이트
async batchUpdatePlacements(layoutId: number, placements: any[]) {
return apiCall("PUT", `/yard-layouts/${layoutId}/placements/batch`, { placements });
},
};
// 자재 관리 API
export const materialApi = {
// 임시 자재 마스터 목록 조회
async getTempMaterials(params?: { search?: string; category?: string; page?: number; limit?: number }) {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append("search", params.search);
if (params?.category) queryParams.append("category", params.category);
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.limit) queryParams.append("limit", params.limit.toString());
const queryString = queryParams.toString();
return apiCall("GET", `/materials/temp${queryString ? `?${queryString}` : ""}`);
},
// 특정 자재 상세 조회
async getTempMaterialByCode(code: string) {
return apiCall("GET", `/materials/temp/${code}`);
},
// 카테고리 목록 조회
async getCategories() {
return apiCall("GET", "/materials/temp/categories");
},
};