From f6848df87ae0ef51c9c8c2ba54969ae38b7be5e3 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 20 Oct 2025 10:57:52 +0900 Subject: [PATCH 01/16] =?UTF-8?q?3D=20=EC=9A=94=EC=86=8C=EA=B0=80=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=EB=B3=B4=EB=8B=A4=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=EC=97=90=20=EB=A0=8C=EB=8D=94=EB=A7=81=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/dashboard/widgets/yard-3d/YardEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 2841c17d..8375999f 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -62,7 +62,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { try { const newPlacementData = { position_x: 0, - position_y: 0, + position_y: 2.5, // size_y의 절반 (5 / 2 = 2.5) position_z: 0, size_x: 5, size_y: 5, From e2d99aef4035117908ce9e439c60574c23134750 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 20 Oct 2025 11:07:58 +0900 Subject: [PATCH 02/16] =?UTF-8?q?quantity=20=ED=83=80=EC=9E=85=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/YardLayoutService.ts | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/backend-node/src/services/YardLayoutService.ts b/backend-node/src/services/YardLayoutService.ts index 64572739..6b1f3fd1 100644 --- a/backend-node/src/services/YardLayoutService.ts +++ b/backend-node/src/services/YardLayoutService.ts @@ -157,14 +157,14 @@ export class YardLayoutService { layoutId, data.material_code || null, data.material_name || null, - data.quantity || null, + data.quantity ? parseInt(String(data.quantity), 10) : null, data.unit || null, - 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.position_x ? parseFloat(String(data.position_x)) : 0, + data.position_y ? parseFloat(String(data.position_y)) : 0, + data.position_z ? parseFloat(String(data.position_z)) : 0, + data.size_x ? parseFloat(String(data.size_x)) : 5, + data.size_y ? parseFloat(String(data.size_y)) : 5, + data.size_z ? parseFloat(String(data.size_z)) : 5, data.color || "#9ca3af", // 미설정 시 회색 data.data_source_type || null, data.data_source_config ? JSON.stringify(data.data_source_config) : null, @@ -204,14 +204,20 @@ export class YardLayoutService { const result = await pool.query(query, [ data.material_code !== undefined ? data.material_code : null, data.material_name !== undefined ? data.material_name : null, - data.quantity !== undefined ? data.quantity : null, + data.quantity !== undefined ? parseInt(String(data.quantity), 10) : null, data.unit !== undefined ? data.unit : null, - data.position_x !== undefined ? data.position_x : null, - data.position_y !== undefined ? data.position_y : null, - data.position_z !== undefined ? data.position_z : null, - data.size_x !== undefined ? data.size_x : null, - data.size_y !== undefined ? data.size_y : null, - data.size_z !== undefined ? data.size_z : null, + data.position_x !== undefined + ? parseFloat(String(data.position_x)) + : null, + data.position_y !== undefined + ? parseFloat(String(data.position_y)) + : null, + data.position_z !== undefined + ? parseFloat(String(data.position_z)) + : null, + data.size_x !== undefined ? parseFloat(String(data.size_x)) : null, + data.size_y !== undefined ? parseFloat(String(data.size_y)) : null, + data.size_z !== undefined ? parseFloat(String(data.size_z)) : null, data.color !== undefined ? data.color : null, data.data_source_type !== undefined ? data.data_source_type : null, data.data_source_config !== undefined From c5a4b0b10c3c0aeda4f7b6f325ef50c096106157 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 20 Oct 2025 11:26:50 +0900 Subject: [PATCH 03/16] =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=9D=84=20=EB=88=84=EB=A5=B4=EA=B8=B0=20=EC=A0=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/YardLayoutService.ts | 65 +++-- .../dashboard/widgets/yard-3d/YardEditor.tsx | 250 ++++++++++-------- .../yard-3d/YardElementConfigPanel.tsx | 89 +++---- 3 files changed, 223 insertions(+), 181 deletions(-) diff --git a/backend-node/src/services/YardLayoutService.ts b/backend-node/src/services/YardLayoutService.ts index 6b1f3fd1..609e3d1c 100644 --- a/backend-node/src/services/YardLayoutService.ts +++ b/backend-node/src/services/YardLayoutService.ts @@ -153,18 +153,35 @@ export class YardLayoutService { `; const pool = getPool(); + + // NaN 방지를 위한 안전한 변환 함수 + const safeParseInt = ( + value: any, + defaultValue: number | null = null + ): number | null => { + if (!value && value !== 0) return defaultValue; + const parsed = parseInt(String(value), 10); + return isNaN(parsed) ? defaultValue : parsed; + }; + + const safeParseFloat = (value: any, defaultValue: number): number => { + if (!value && value !== 0) return defaultValue; + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? defaultValue : parsed; + }; + const result = await pool.query(query, [ layoutId, data.material_code || null, data.material_name || null, - data.quantity ? parseInt(String(data.quantity), 10) : null, + safeParseInt(data.quantity, null), data.unit || null, - data.position_x ? parseFloat(String(data.position_x)) : 0, - data.position_y ? parseFloat(String(data.position_y)) : 0, - data.position_z ? parseFloat(String(data.position_z)) : 0, - data.size_x ? parseFloat(String(data.size_x)) : 5, - data.size_y ? parseFloat(String(data.size_y)) : 5, - data.size_z ? parseFloat(String(data.size_z)) : 5, + safeParseFloat(data.position_x, 0), + safeParseFloat(data.position_y, 0), + safeParseFloat(data.position_z, 0), + safeParseFloat(data.size_x, 5), + safeParseFloat(data.size_y, 5), + safeParseFloat(data.size_z, 5), data.color || "#9ca3af", // 미설정 시 회색 data.data_source_type || null, data.data_source_config ? JSON.stringify(data.data_source_config) : null, @@ -201,23 +218,31 @@ export class YardLayoutService { `; const pool = getPool(); + + // NaN 방지를 위한 안전한 변환 함수 + const safeParseInt = (value: any): number | null => { + if (value === null || value === undefined) return null; + const parsed = parseInt(String(value), 10); + return isNaN(parsed) ? null : parsed; + }; + + const safeParseFloat = (value: any): number | null => { + if (value === null || value === undefined) return null; + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? null : parsed; + }; + const result = await pool.query(query, [ data.material_code !== undefined ? data.material_code : null, data.material_name !== undefined ? data.material_name : null, - data.quantity !== undefined ? parseInt(String(data.quantity), 10) : null, + data.quantity !== undefined ? safeParseInt(data.quantity) : null, data.unit !== undefined ? data.unit : null, - data.position_x !== undefined - ? parseFloat(String(data.position_x)) - : null, - data.position_y !== undefined - ? parseFloat(String(data.position_y)) - : null, - data.position_z !== undefined - ? parseFloat(String(data.position_z)) - : null, - data.size_x !== undefined ? parseFloat(String(data.size_x)) : null, - data.size_y !== undefined ? parseFloat(String(data.size_y)) : null, - data.size_z !== undefined ? parseFloat(String(data.size_z)) : null, + data.position_x !== undefined ? safeParseFloat(data.position_x) : null, + data.position_y !== undefined ? safeParseFloat(data.position_y) : null, + data.position_z !== undefined ? safeParseFloat(data.position_z) : null, + data.size_x !== undefined ? safeParseFloat(data.size_x) : null, + data.size_y !== undefined ? safeParseFloat(data.size_y) : null, + data.size_z !== undefined ? safeParseFloat(data.size_z) : null, data.color !== undefined ? data.color : null, data.data_source_type !== undefined ? data.data_source_type : null, data.data_source_config !== undefined diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 8375999f..faa25e3c 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -31,11 +31,14 @@ interface YardEditorProps { export default function YardEditor({ layout, onBack }: YardEditorProps) { const [placements, setPlacements] = useState([]); + const [originalPlacements, setOriginalPlacements] = useState([]); // 원본 데이터 보관 const [selectedPlacement, setSelectedPlacement] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [showConfigPanel, setShowConfigPanel] = useState(false); const [error, setError] = useState(null); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 미저장 변경사항 추적 + const [nextPlacementId, setNextPlacementId] = useState(-1); // 임시 ID (음수 사용) // 배치 목록 로드 useEffect(() => { @@ -44,7 +47,9 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setIsLoading(true); const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); if (response.success) { - setPlacements(response.data as YardPlacement[]); + const loadedData = response.data as YardPlacement[]; + setPlacements(loadedData); + setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사 } } catch (error) { console.error("배치 목록 로드 실패:", error); @@ -57,36 +62,34 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { loadPlacements(); }, [layout.id]); - // 빈 요소 추가 - const handleAddElement = async () => { - try { - const newPlacementData = { - position_x: 0, - position_y: 2.5, // size_y의 절반 (5 / 2 = 2.5) - position_z: 0, - size_x: 5, - size_y: 5, - size_z: 5, - color: "#9ca3af", // 회색 (미설정 상태) - }; + // 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영) + const handleAddElement = () => { + const newPlacement: YardPlacement = { + id: nextPlacementId, // 임시 음수 ID + yard_layout_id: layout.id, + material_code: null, + material_name: null, + quantity: null, + unit: null, + position_x: 0, + position_y: 2.5, + position_z: 0, + size_x: 5, + size_y: 5, + size_z: 5, + color: "#9ca3af", + data_source_type: null, + data_source_config: null, + data_binding: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; - console.log("요소 추가 요청:", { layoutId: layout.id, data: newPlacementData }); - const response = await yardLayoutApi.addMaterialPlacement(layout.id, newPlacementData); - console.log("요소 추가 응답:", response); - - if (response.success) { - const newPlacement = response.data as YardPlacement; - setPlacements((prev) => [...prev, newPlacement]); - setSelectedPlacement(newPlacement); - setShowConfigPanel(true); // 자동으로 설정 패널 표시 - } else { - console.error("요소 추가 실패 (응답):", response); - setError(response.message || "요소 추가에 실패했습니다."); - } - } catch (error) { - console.error("요소 추가 실패 (예외):", error); - setError(`요소 추가에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`); - } + setPlacements((prev) => [...prev, newPlacement]); + setSelectedPlacement(newPlacement); + setShowConfigPanel(true); + setHasUnsavedChanges(true); + setNextPlacementId((prev) => prev - 1); // 다음 임시 ID }; // 요소 선택 (3D 캔버스 또는 목록에서) @@ -101,28 +104,21 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setShowConfigPanel(true); }; - // 요소 삭제 - const handleDeletePlacement = async (placementId: number) => { + // 요소 삭제 (로컬 상태에서만 삭제, 저장 시 서버에 반영) + const handleDeletePlacement = (placementId: number) => { if (!confirm("이 요소를 삭제하시겠습니까?")) { return; } - try { - const response = await yardLayoutApi.removePlacement(placementId); - if (response.success) { - setPlacements((prev) => prev.filter((p) => p.id !== placementId)); - if (selectedPlacement?.id === placementId) { - setSelectedPlacement(null); - setShowConfigPanel(false); - } - } - } catch (error) { - console.error("요소 삭제 실패:", error); - setError("요소 삭제에 실패했습니다."); + setPlacements((prev) => prev.filter((p) => p.id !== placementId)); + if (selectedPlacement?.id === placementId) { + setSelectedPlacement(null); + setShowConfigPanel(false); } + setHasUnsavedChanges(true); }; - // 자재 드래그 (3D 캔버스에서) + // 자재 드래그 (3D 캔버스에서, 로컬 상태에만 반영) const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => { const updatedPosition = { position_x: Math.round(position.x * 2) / 2, @@ -135,27 +131,83 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { if (selectedPlacement?.id === id) { setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedPosition } : null)); } + + setHasUnsavedChanges(true); }; - // 저장 + // 전체 저장 (신규/수정/삭제 모두 처리) 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, - })), - ); + // 1. 삭제된 요소 처리 (원본에는 있지만 현재 state에 없는 경우) + const deletedIds = originalPlacements + .filter((orig) => !placements.find((p) => p.id === orig.id)) + .map((p) => p.id); + for (const id of deletedIds) { + await yardLayoutApi.removePlacement(id); + } + + // 2. 신규 추가 요소 처리 (ID가 음수인 경우) + const newPlacements = placements.filter((p) => p.id < 0); + const addedPlacements: YardPlacement[] = []; + + for (const newPlacement of newPlacements) { + const response = await yardLayoutApi.addMaterialPlacement(layout.id, { + material_code: newPlacement.material_code, + material_name: newPlacement.material_name, + quantity: newPlacement.quantity, + unit: newPlacement.unit, + position_x: newPlacement.position_x, + position_y: newPlacement.position_y, + position_z: newPlacement.position_z, + size_x: newPlacement.size_x, + size_y: newPlacement.size_y, + size_z: newPlacement.size_z, + color: newPlacement.color, + data_source_type: newPlacement.data_source_type, + data_source_config: newPlacement.data_source_config, + data_binding: newPlacement.data_binding, + memo: newPlacement.memo, + }); + + if (response.success) { + addedPlacements.push(response.data as YardPlacement); + } + } + + // 3. 기존 요소 수정 처리 (ID가 양수인 경우) + const existingPlacements = placements.filter((p) => p.id > 0); + + for (const placement of existingPlacements) { + await yardLayoutApi.updatePlacement(placement.id, { + material_code: placement.material_code, + material_name: placement.material_name, + quantity: placement.quantity, + unit: placement.unit, + 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, + data_source_type: placement.data_source_type, + data_source_config: placement.data_source_config, + data_binding: placement.data_binding, + memo: placement.memo, + }); + } + + // 4. 저장 성공 후 데이터 다시 로드 + const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); if (response.success) { + const loadedData = response.data as YardPlacement[]; + setPlacements(loadedData); + setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); + setHasUnsavedChanges(false); + setSelectedPlacement(null); + setShowConfigPanel(false); alert("저장되었습니다"); } } catch (error) { @@ -166,45 +218,24 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { } }; - // 설정 패널에서 저장 - const handleSaveConfig = async (updatedData: Partial) => { + // 설정 패널에서 데이터 업데이트 (로컬 상태에만 반영, 서버 저장 안함) + const handleSaveConfig = (updatedData: Partial) => { if (!selectedPlacement) return; - try { - const response = await yardLayoutApi.updatePlacement(selectedPlacement.id, updatedData); - if (response.success) { - const updated = response.data as YardPlacement; + // 로컬 상태만 업데이트 + setPlacements((prev) => + prev.map((p) => { + if (p.id === selectedPlacement.id) { + return { ...p, ...updatedData }; + } + return p; + }), + ); - // 현재 위치 정보를 유지하면서 업데이트 - setPlacements((prev) => - prev.map((p) => { - if (p.id === updated.id) { - // 현재 프론트엔드 상태의 위치를 유지 - return { - ...updated, - position_x: p.position_x, - position_y: p.position_y, - position_z: p.position_z, - }; - } - return p; - }), - ); - - // 선택된 요소도 동일하게 업데이트 - setSelectedPlacement({ - ...updated, - position_x: selectedPlacement.position_x, - position_y: selectedPlacement.position_y, - position_z: selectedPlacement.position_z, - }); - - setShowConfigPanel(false); - } - } catch (error) { - console.error("설정 저장 실패:", error); - setError("설정 저장에 실패했습니다."); - } + // 선택된 요소도 업데이트 + setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedData } : null)); + setShowConfigPanel(false); + setHasUnsavedChanges(true); }; // 요소가 설정되었는지 확인 @@ -227,19 +258,22 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { - +
+ {hasUnsavedChanges && 미저장 변경사항 있음} + +
{/* 에러 메시지 */} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx index 27c0860f..efd86e6c 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx @@ -18,7 +18,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; interface YardElementConfigPanelProps { placement: YardPlacement; - onSave: (updatedData: Partial) => Promise; + onSave: (updatedData: Partial) => void; // Promise 제거 (즉시 로컬 상태 업데이트) onCancel: () => void; } @@ -52,9 +52,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }: const [sizeY, setSizeY] = useState(placement.size_y); const [sizeZ, setSizeZ] = useState(placement.size_z); - // 에러 및 로딩 + // 에러 const [error, setError] = useState(null); - const [isSaving, setIsSaving] = useState(false); // 외부 DB 커넥션 목록 로드 useEffect(() => { @@ -180,8 +179,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }: } }; - // 저장 - const handleSave = async () => { + // 적용 (로컬 상태만 업데이트, 서버 저장은 나중에 일괄 처리) + const handleApply = () => { // 검증 if (!queryResult) { setError("먼저 데이터를 조회해주세요."); @@ -203,49 +202,40 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }: return; } - setIsSaving(true); + const selectedRow = queryResult.rows[selectedRowIndex]; + const materialName = selectedRow[materialNameField]; + const quantity = selectedRow[quantityField]; - try { - const selectedRow = queryResult.rows[selectedRowIndex]; - const materialName = selectedRow[materialNameField]; - const quantity = selectedRow[quantityField]; + const dataSourceConfig: YardDataSourceConfig = { + type: dataSourceType, + query: dataSourceType !== "rest_api" ? query : undefined, + connectionId: dataSourceType === "external_db" ? parseInt(externalConnectionId) : undefined, + url: dataSourceType === "rest_api" ? apiUrl : undefined, + method: dataSourceType === "rest_api" ? apiMethod : undefined, + dataPath: dataSourceType === "rest_api" && apiDataPath ? apiDataPath : undefined, + }; - const dataSourceConfig: YardDataSourceConfig = { - type: dataSourceType, - query: dataSourceType !== "rest_api" ? query : undefined, - connectionId: dataSourceType === "external_db" ? parseInt(externalConnectionId) : undefined, - url: dataSourceType === "rest_api" ? apiUrl : undefined, - method: dataSourceType === "rest_api" ? apiMethod : undefined, - dataPath: dataSourceType === "rest_api" && apiDataPath ? apiDataPath : undefined, - }; + const dataBinding: YardDataBinding = { + selectedRowIndex, + materialNameField, + quantityField, + unit: unit.trim(), + }; - const dataBinding: YardDataBinding = { - selectedRowIndex, - materialNameField, - quantityField, - unit: unit.trim(), - }; + const updatedData: Partial = { + material_name: String(materialName), + quantity: Number(quantity), + unit: unit.trim(), + color, + size_x: sizeX, + size_y: sizeY, + size_z: sizeZ, + data_source_type: dataSourceType, + data_source_config: dataSourceConfig, + data_binding: dataBinding, + }; - const updatedData: Partial = { - material_name: String(materialName), - quantity: Number(quantity), - unit: unit.trim(), - color, - size_x: sizeX, - size_y: sizeY, - size_z: sizeZ, - data_source_type: dataSourceType, - data_source_config: dataSourceConfig, - data_binding: dataBinding, - }; - - await onSave(updatedData); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "저장 중 오류가 발생했습니다."; - setError(errorMessage); - } finally { - setIsSaving(false); - } + onSave(updatedData); // 동기적으로 즉시 로컬 상태 업데이트 }; return ( @@ -537,15 +527,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }: - From cd893c2fa30a25f3aa23342383ea68192e80db80 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 20 Oct 2025 11:30:09 +0900 Subject: [PATCH 04/16] =?UTF-8?q?alert=EB=A5=BC=20Dialog=20=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/yard-3d/YardEditor.tsx | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index faa25e3c..346d59f5 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -7,7 +7,8 @@ import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; import dynamic from "next/dynamic"; import { YardLayout, YardPlacement } from "./types"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, CheckCircle } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -39,6 +40,11 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { const [error, setError] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 미저장 변경사항 추적 const [nextPlacementId, setNextPlacementId] = useState(-1); // 임시 ID (음수 사용) + const [saveResultDialog, setSaveResultDialog] = useState<{ + open: boolean; + success: boolean; + message: string; + }>({ open: false, success: false, message: "" }); // 배치 목록 로드 useEffect(() => { @@ -208,11 +214,19 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setHasUnsavedChanges(false); setSelectedPlacement(null); setShowConfigPanel(false); - alert("저장되었습니다"); + setSaveResultDialog({ + open: true, + success: true, + message: "모든 변경사항이 성공적으로 저장되었습니다.", + }); } } catch (error) { console.error("저장 실패:", error); - alert("저장에 실패했습니다"); + setSaveResultDialog({ + open: true, + success: false, + message: `저장에 실패했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`, + }); } finally { setIsSaving(false); } @@ -403,6 +417,31 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { )} + + {/* 저장 결과 Dialog */} + setSaveResultDialog((prev) => ({ ...prev, open }))}> + e.stopPropagation()}> + + + {saveResultDialog.success ? ( + <> + + 저장 완료 + + ) : ( + <> + + 저장 실패 + + )} + + {saveResultDialog.message} + +
+ +
+
+
); } From 49669b37e52db5ff96af14044df121362b29242e Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 20 Oct 2025 11:39:50 +0900 Subject: [PATCH 05/16] =?UTF-8?q?=EC=95=BC=EB=93=9C=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 8 ++ .../widgets/YardWidgetConfigModal.tsx | 79 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 frontend/components/admin/dashboard/widgets/YardWidgetConfigModal.tsx diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 27115ee1..e0b5778f 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -6,6 +6,7 @@ import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; +import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; @@ -495,6 +496,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onClose={closeConfigModal} onSave={saveListWidgetConfig} /> + ) : configModalElement.type === "widget" && configModalElement.subtype === "yard-management-3d" ? ( + ) : ( void; + onSave: (updates: Partial) => void; +} + +export function YardWidgetConfigModal({ element, isOpen, onClose, onSave }: YardWidgetConfigModalProps) { + const [customTitle, setCustomTitle] = useState(element.customTitle || ""); + const [showHeader, setShowHeader] = useState(element.showHeader !== false); + + useEffect(() => { + if (isOpen) { + setCustomTitle(element.customTitle || ""); + setShowHeader(element.showHeader !== false); + } + }, [isOpen, element]); + + const handleSave = () => { + onSave({ + customTitle, + showHeader, + }); + onClose(); + }; + + return ( + + e.stopPropagation()} className="sm:max-w-[500px]"> + + 야드 관리 위젯 설정 + + +
+ {/* 위젯 제목 */} +
+ + setCustomTitle(e.target.value)} + placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)" + /> +

기본 제목: 야드 관리 3D

+
+ + {/* 헤더 표시 여부 */} +
+ setShowHeader(checked === true)} + /> + +
+
+ +
+ + +
+
+
+ ); +} From ff58c84ac0294c505da0ca951192aba45575e638 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 20 Oct 2025 11:52:23 +0900 Subject: [PATCH 06/16] =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=82=9C=EA=B2=83?= =?UTF-8?q?=EB=93=A4=20=ED=95=B4=EA=B2=B0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 8 ++-- .../admin/dashboard/DashboardCanvas.tsx | 20 ++++++---- .../admin/dashboard/DashboardDesigner.tsx | 40 ++++++++++++------- .../admin/dashboard/ElementConfigModal.tsx | 31 +++++++++++--- .../widgets/YardManagement3DWidget.tsx | 15 +++++++ .../components/dashboard/DashboardViewer.tsx | 8 ++++ 6 files changed, 91 insertions(+), 31 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index f725497c..070116f0 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; -import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils"; +import { GRID_CONFIG } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -116,6 +116,7 @@ interface CanvasElementProps { element: DashboardElement; isSelected: boolean; cellSize: number; + subGridSize: number; canvasWidth?: number; onUpdate: (id: string, updates: Partial) => void; onRemove: (id: string) => void; @@ -133,6 +134,7 @@ export function CanvasElement({ element, isSelected, cellSize, + subGridSize, canvasWidth = 1560, onUpdate, onRemove, @@ -233,7 +235,6 @@ export function CanvasElement({ rawX = Math.min(rawX, maxX); // 드래그 중 실시간 스냅 (마그네틱 스냅) - const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px) @@ -291,7 +292,6 @@ export function CanvasElement({ newWidth = Math.min(newWidth, maxWidth); // 리사이즈 중 실시간 스냅 (마그네틱 스냅) - const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; @@ -336,6 +336,7 @@ export function CanvasElement({ element.subtype, canvasWidth, cellSize, + subGridSize, ], ); @@ -726,6 +727,7 @@ export function CanvasElement({ isEditMode={true} config={element.yardConfig} onConfigChange={(newConfig) => { + console.log("🏗️ 야드 설정 업데이트:", { elementId: element.id, newConfig }); onUpdate(element.id, { yardConfig: newConfig }); }} /> diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 45d2cf3c..3170880a 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -156,8 +156,7 @@ export const DashboardCanvas = forwardRef( const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) - const subGridSize = Math.floor(cellSize / 3); - const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // X 좌표 스냅 @@ -196,6 +195,9 @@ export const DashboardCanvas = forwardRef( // 동적 그리드 크기 계산 const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; + + // 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용) + const subGridSize = gridConfig.SUB_GRID_SIZE; // 12개 컬럼 구분선 위치 계산 const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap); @@ -208,12 +210,12 @@ export const DashboardCanvas = forwardRef( backgroundColor, height: `${canvasHeight}px`, minHeight: `${canvasHeight}px`, - // 세밀한 그리드 배경 + // 서브그리드 배경 (세밀한 점선) backgroundImage: ` - linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px), - linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px) + linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px) `, - backgroundSize: gridSize, + backgroundSize: `${subGridSize}px ${subGridSize}px`, backgroundPosition: "0 0", backgroundRepeat: "repeat", }} @@ -229,8 +231,9 @@ export const DashboardCanvas = forwardRef( className="pointer-events-none absolute top-0 h-full" style={{ left: `${x}px`, - width: "2px", - zIndex: 1, + width: "1px", + backgroundColor: i === 0 || i === GRID_CONFIG.COLUMNS ? "rgba(59, 130, 246, 0.3)" : "rgba(59, 130, 246, 0.15)", + zIndex: 0, }} /> ))} @@ -248,6 +251,7 @@ export const DashboardCanvas = forwardRef( element={element} isSelected={selectedElement === element.id} cellSize={cellSize} + subGridSize={subGridSize} canvasWidth={canvasWidth} onUpdate={handleUpdateWithCollisionDetection} onRemove={onRemoveElement} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 27115ee1..9a776de8 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -332,21 +332,31 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D try { const { dashboardApi } = await import("@/lib/api/dashboard"); - const elementsData = elements.map((el) => ({ - id: el.id, - type: el.type, - subtype: el.subtype, - position: el.position, - size: el.size, - title: el.title, - customTitle: el.customTitle, - showHeader: el.showHeader, - content: el.content, - dataSource: el.dataSource, - chartConfig: el.chartConfig, - listConfig: el.listConfig, - yardConfig: el.yardConfig, - })); + const elementsData = elements.map((el) => { + // 야드 위젯인 경우 설정 로그 출력 + if (el.subtype === "yard-management-3d") { + console.log("💾 야드 위젯 저장:", { + id: el.id, + yardConfig: el.yardConfig, + hasLayoutId: !!el.yardConfig?.layoutId, + }); + } + return { + id: el.id, + type: el.type, + subtype: el.subtype, + position: el.position, + size: el.size, + title: el.title, + customTitle: el.customTitle, + showHeader: el.showHeader, + content: el.content, + dataSource: el.dataSource, + chartConfig: el.chartConfig, + listConfig: el.listConfig, + yardConfig: el.yardConfig, + }; + }); let savedDashboard; diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index ad4de687..fdfcc2c2 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -36,6 +36,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) const isSimpleWidget = + element.subtype === "todo" || // To-Do 위젯 + element.subtype === "booking-alert" || // 예약 알림 위젯 + element.subtype === "maintenance" || // 정비 일정 위젯 + element.subtype === "document" || // 문서 위젯 + element.subtype === "risk-alert" || // 리스크 알림 위젯 element.subtype === "vehicle-status" || element.subtype === "vehicle-list" || element.subtype === "status-summary" || // 커스텀 상태 카드 @@ -46,6 +51,12 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element element.subtype === "cargo-list" || element.subtype === "customer-issues" || element.subtype === "driver-management"; + + // 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능) + const isSelfContainedWidget = + element.subtype === "weather" || // 날씨 위젯 (외부 API) + element.subtype === "exchange" || // 환율 위젯 (외부 API) + element.subtype === "calculator"; // 계산기 위젯 (자체 기능) // 지도 위젯 (위도/경도 매핑 필요) const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; @@ -59,6 +70,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element setQueryResult(null); setCurrentStep(1); setCustomTitle(element.customTitle || ""); + setShowHeader(element.showHeader !== false); // showHeader 초기화 } }, [isOpen, element]); @@ -135,8 +147,12 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 모달이 열려있지 않으면 렌더링하지 않음 if (!isOpen) return null; - // 시계, 달력, To-Do 위젯은 헤더 설정만 가능 - const isHeaderOnlyWidget = element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "todo"); + // 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능 + const isHeaderOnlyWidget = + element.type === "widget" && + (element.subtype === "clock" || + element.subtype === "calendar" || + isSelfContainedWidget); // 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음 if (element.type === "widget" && element.subtype === "driver-management") { @@ -154,11 +170,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // customTitle이 변경되었는지 확인 const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); + + // showHeader가 변경되었는지 확인 + const isHeaderChanged = showHeader !== (element.showHeader !== false); const canSave = isTitleChanged || // 제목만 변경해도 저장 가능 + isHeaderChanged || // 헤더 표시 여부만 변경해도 저장 가능 (isSimpleWidget - ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 + ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요) currentStep === 2 && queryResult && queryResult.rows.length > 0 : isMapWidget ? // 지도 위젯: 위도/경도 매핑 필요 @@ -184,7 +204,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
{/* 모달 헤더 */} @@ -336,7 +356,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element 저장 ) : currentStep === 1 ? ( - // 1단계: 다음 버튼 + // 1단계: 다음 버튼 (차트 위젯, 간단한 위젯 모두)
); } + diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index c09e7df6..f3e5a1be 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -57,6 +57,17 @@ export default function YardManagement3DWidget({ } }, [isEditMode]); + // 레이아웃 목록이 로드되었고, 설정이 없으면 첫 번째 레이아웃 자동 선택 + useEffect(() => { + if (isEditMode && layouts.length > 0 && !config?.layoutId && onConfigChange) { + console.log("🔧 첫 번째 야드 레이아웃 자동 선택:", layouts[0]); + onConfigChange({ + layoutId: layouts[0].id, + layoutName: layouts[0].name, + }); + } + }, [isEditMode, layouts, config?.layoutId, onConfigChange]); + // 레이아웃 선택 (편집 모드에서만) const handleSelectLayout = (layout: YardLayout) => { if (onConfigChange) { @@ -243,12 +254,16 @@ export default function YardManagement3DWidget({ // 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시 if (!config?.layoutId) { + console.warn("⚠️ 야드관리 위젯: layoutId가 설정되지 않음", { config, isEditMode }); return (
🏗️
야드 레이아웃이 설정되지 않았습니다
대시보드 편집에서 레이아웃을 선택하세요
+
+ 디버그: config={JSON.stringify(config)} +
); diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 9b6e83f8..cec206ff 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -82,6 +82,14 @@ function renderWidget(element: DashboardElement) { return ; case "yard-management-3d": + console.log("🏗️ 야드관리 위젯 렌더링:", { + elementId: element.id, + yardConfig: element.yardConfig, + yardConfigType: typeof element.yardConfig, + hasLayoutId: !!element.yardConfig?.layoutId, + layoutId: element.yardConfig?.layoutId, + layoutName: element.yardConfig?.layoutName, + }); return ; // === 차량 관련 (추가 위젯) === From 821be53b19ea6e64da0cc8002278173ffc0dd52a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 20 Oct 2025 12:07:07 +0900 Subject: [PATCH 07/16] =?UTF-8?q?=EC=95=BC=EB=93=9C3D=20=EC=9A=94=EC=86=8C?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20Dialog=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/yard-3d/YardEditor.tsx | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 346d59f5..473ae3b8 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -45,6 +45,10 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { success: boolean; message: string; }>({ open: false, success: false, message: "" }); + const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<{ + open: boolean; + placementId: number | null; + }>({ open: false, placementId: null }); // 배치 목록 로드 useEffect(() => { @@ -110,11 +114,15 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setShowConfigPanel(true); }; - // 요소 삭제 (로컬 상태에서만 삭제, 저장 시 서버에 반영) + // 요소 삭제 확인 Dialog 열기 const handleDeletePlacement = (placementId: number) => { - if (!confirm("이 요소를 삭제하시겠습니까?")) { - return; - } + setDeleteConfirmDialog({ open: true, placementId }); + }; + + // 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영) + const confirmDeletePlacement = () => { + const { placementId } = deleteConfirmDialog; + if (placementId === null) return; setPlacements((prev) => prev.filter((p) => p.id !== placementId)); if (selectedPlacement?.id === placementId) { @@ -122,6 +130,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setShowConfigPanel(false); } setHasUnsavedChanges(true); + setDeleteConfirmDialog({ open: false, placementId: null }); }; // 자재 드래그 (3D 캔버스에서, 로컬 상태에만 반영) @@ -442,6 +451,34 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
+ + {/* 삭제 확인 Dialog */} + !open && setDeleteConfirmDialog({ open: false, placementId: null })} + > + e.stopPropagation()}> + + + + 요소 삭제 확인 + + + 이 요소를 삭제하시겠습니까? +
+ 저장 버튼을 눌러야 최종적으로 삭제됩니다. +
+
+
+ + +
+
+
); } From f16f75c0830af39dff4dd0144ea15dd564a50fc8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 20 Oct 2025 12:29:47 +0900 Subject: [PATCH 08/16] =?UTF-8?q?=EC=95=BC=EB=93=9C=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/yard-3d/YardEditor.tsx | 79 ++++++++++++++++++- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 473ae3b8..41c68af5 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -2,13 +2,15 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Plus, Settings, Trash2 } from "lucide-react"; +import { ArrowLeft, Save, Loader2, Plus, Settings, Trash2, Edit2 } from "lucide-react"; import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; import dynamic from "next/dynamic"; import { YardLayout, YardPlacement } from "./types"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertCircle, CheckCircle } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -49,6 +51,10 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { open: boolean; placementId: number | null; }>({ open: false, placementId: null }); + const [editLayoutDialog, setEditLayoutDialog] = useState<{ + open: boolean; + name: string; + }>({ open: false, name: "" }); // 배치 목록 로드 useEffect(() => { @@ -266,6 +272,32 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { return !!(placement.material_name && placement.quantity && placement.unit); }; + // 레이아웃 편집 Dialog 열기 + const handleEditLayout = () => { + setEditLayoutDialog({ + open: true, + name: layout.name, + }); + }; + + // 레이아웃 정보 저장 + const handleSaveLayoutInfo = async () => { + try { + const response = await yardLayoutApi.updateLayout(layout.id, { + name: editLayoutDialog.name, + }); + + if (response.success) { + // 레이아웃 정보 업데이트 + layout.name = editLayoutDialog.name; + setEditLayoutDialog({ open: false, name: "" }); + } + } catch (error) { + console.error("레이아웃 정보 수정 실패:", error); + setError("레이아웃 정보 수정에 실패했습니다."); + } + }; + return (
{/* 상단 툴바 */} @@ -275,9 +307,14 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { 목록으로 -
-

{layout.name}

- {layout.description &&

{layout.description}

} +
+
+

{layout.name}

+ {layout.description &&

{layout.description}

} +
+
@@ -479,6 +516,40 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
+ + {/* 레이아웃 편집 Dialog */} + !open && setEditLayoutDialog({ open: false, name: "" })} + > + e.stopPropagation()}> + + + + 야드 레이아웃 정보 수정 + + +
+
+ + setEditLayoutDialog((prev) => ({ ...prev, name: e.target.value }))} + placeholder="레이아웃 이름을 입력하세요" + /> +
+
+
+ + +
+
+
); } From 5b503edfa8747705d7a630e977a126a654bb4be0 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 20 Oct 2025 14:07:08 +0900 Subject: [PATCH 09/16] =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=20=ED=86=B5=EA=B3=84=20=EC=9C=84=EC=A0=AF=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=B1=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=95=88?= =?UTF-8?q?=EB=A8=B9=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EB=93=B1=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WORK_HISTORY_SETUP.md | 150 ++++++++ backend-node/src/app.ts | 2 + .../src/controllers/workHistoryController.ts | 199 +++++++++++ backend-node/src/routes/workHistoryRoutes.ts | 35 ++ backend-node/src/services/dataService.ts | 2 + .../src/services/workHistoryService.ts | 335 ++++++++++++++++++ backend-node/src/types/workHistory.ts | 114 ++++++ .../admin/dashboard/CanvasElement.tsx | 22 ++ .../admin/dashboard/DashboardDesigner.tsx | 8 + .../admin/dashboard/DashboardSaveModal.tsx | 8 + .../admin/dashboard/DashboardSidebar.tsx | 12 + .../admin/dashboard/DashboardTopMenu.tsx | 1 + .../admin/dashboard/ElementConfigModal.tsx | 4 +- .../admin/dashboard/QueryEditor.tsx | 4 + frontend/components/admin/dashboard/types.ts | 4 +- .../components/dashboard/DashboardViewer.tsx | 14 + .../widgets/TransportStatsWidget.tsx | 227 ++++++++++++ .../dashboard/widgets/WorkHistoryWidget.tsx | 222 ++++++++++++ frontend/types/workHistory.ts | 86 +++++ 19 files changed, 1447 insertions(+), 2 deletions(-) create mode 100644 WORK_HISTORY_SETUP.md create mode 100644 backend-node/src/controllers/workHistoryController.ts create mode 100644 backend-node/src/routes/workHistoryRoutes.ts create mode 100644 backend-node/src/services/workHistoryService.ts create mode 100644 backend-node/src/types/workHistory.ts create mode 100644 frontend/components/dashboard/widgets/TransportStatsWidget.tsx create mode 100644 frontend/components/dashboard/widgets/WorkHistoryWidget.tsx create mode 100644 frontend/types/workHistory.ts diff --git a/WORK_HISTORY_SETUP.md b/WORK_HISTORY_SETUP.md new file mode 100644 index 00000000..223b3975 --- /dev/null +++ b/WORK_HISTORY_SETUP.md @@ -0,0 +1,150 @@ +# 작업 이력 관리 시스템 설치 가이드 + +## 📋 개요 + +작업 이력 관리 시스템이 추가되었습니다. 입고/출고/이송/정비 작업을 관리하고 통계를 확인할 수 있습니다. + +## 🚀 설치 방법 + +### 1. 데이터베이스 마이그레이션 실행 + +PostgreSQL 데이터베이스에 작업 이력 테이블을 생성해야 합니다. + +```bash +# 방법 1: psql 명령어 사용 (로컬 PostgreSQL) +psql -U postgres -d plm -f db/migrations/20241020_create_work_history.sql + +# 방법 2: Docker 컨테이너 사용 +docker exec -i psql -U postgres -d plm < db/migrations/20241020_create_work_history.sql + +# 방법 3: pgAdmin 또는 DBeaver 사용 +# db/migrations/20241020_create_work_history.sql 파일을 열어서 실행 +``` + +### 2. 백엔드 재시작 + +```bash +cd backend-node +npm run dev +``` + +### 3. 프론트엔드 확인 + +대시보드 편집 화면에서 다음 위젯들을 추가할 수 있습니다: + +- **작업 이력**: 작업 목록을 테이블 형식으로 표시 +- **운송 통계**: 오늘 작업, 총 운송량, 정시 도착률 등 통계 표시 + +## 📊 주요 기능 + +### 작업 이력 위젯 + +- 작업 번호, 일시, 유형, 차량, 경로, 화물, 중량, 상태 표시 +- 유형별 필터링 (입고/출고/이송/정비) +- 상태별 필터링 (대기/진행중/완료/취소) +- 실시간 자동 새로고침 + +### 운송 통계 위젯 + +- 오늘 작업 건수 및 완료율 +- 총 운송량 (톤) +- 누적 거리 (km) +- 정시 도착률 (%) +- 작업 유형별 분포 차트 + +## 🔧 API 엔드포인트 + +### 작업 이력 관리 + +- `GET /api/work-history` - 작업 이력 목록 조회 +- `GET /api/work-history/:id` - 작업 이력 단건 조회 +- `POST /api/work-history` - 작업 이력 생성 +- `PUT /api/work-history/:id` - 작업 이력 수정 +- `DELETE /api/work-history/:id` - 작업 이력 삭제 + +### 통계 및 분석 + +- `GET /api/work-history/stats` - 작업 이력 통계 +- `GET /api/work-history/trend?months=6` - 월별 추이 +- `GET /api/work-history/routes?limit=5` - 주요 운송 경로 + +## 📝 샘플 데이터 + +마이그레이션 실행 시 자동으로 4건의 샘플 데이터가 생성됩니다: + +1. 입고 작업 (완료) +2. 출고 작업 (진행중) +3. 이송 작업 (대기) +4. 정비 작업 (완료) + +## 🎯 사용 방법 + +### 1. 대시보드에 위젯 추가 + +1. 대시보드 편집 모드로 이동 +2. 상단 메뉴에서 "위젯 추가" 선택 +3. "작업 이력" 또는 "운송 통계" 선택 +4. 원하는 위치에 배치 +5. 저장 + +### 2. 작업 이력 필터링 + +- 유형 선택: 전체/입고/출고/이송/정비 +- 상태 선택: 전체/대기/진행중/완료/취소 +- 새로고침 버튼으로 수동 갱신 + +### 3. 통계 확인 + +운송 통계 위젯에서 다음 정보를 확인할 수 있습니다: + +- 오늘 작업 건수 +- 완료율 +- 총 운송량 +- 정시 도착률 +- 작업 유형별 분포 + +## 🔍 문제 해결 + +### 데이터가 표시되지 않는 경우 + +1. 데이터베이스 마이그레이션이 실행되었는지 확인 +2. 백엔드 서버가 실행 중인지 확인 +3. 브라우저 콘솔에서 API 에러 확인 + +### API 에러가 발생하는 경우 + +```bash +# 백엔드 로그 확인 +cd backend-node +npm run dev +``` + +### 위젯이 표시되지 않는 경우 + +1. 프론트엔드 재시작 +2. 브라우저 캐시 삭제 +3. 페이지 새로고침 + +## 📚 관련 파일 + +### 백엔드 + +- `backend-node/src/types/workHistory.ts` - 타입 정의 +- `backend-node/src/services/workHistoryService.ts` - 비즈니스 로직 +- `backend-node/src/controllers/workHistoryController.ts` - API 컨트롤러 +- `backend-node/src/routes/workHistoryRoutes.ts` - 라우트 정의 + +### 프론트엔드 + +- `frontend/types/workHistory.ts` - 타입 정의 +- `frontend/components/dashboard/widgets/WorkHistoryWidget.tsx` - 작업 이력 위젯 +- `frontend/components/dashboard/widgets/TransportStatsWidget.tsx` - 운송 통계 위젯 + +### 데이터베이스 + +- `db/migrations/20241020_create_work_history.sql` - 테이블 생성 스크립트 + +## 🎉 완료! + +작업 이력 관리 시스템이 성공적으로 설치되었습니다! + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 0e41697f..caa010b4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -56,6 +56,7 @@ import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D +import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -206,6 +207,7 @@ app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D +app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/workHistoryController.ts b/backend-node/src/controllers/workHistoryController.ts new file mode 100644 index 00000000..8648a385 --- /dev/null +++ b/backend-node/src/controllers/workHistoryController.ts @@ -0,0 +1,199 @@ +/** + * 작업 이력 관리 컨트롤러 + */ + +import { Request, Response } from 'express'; +import * as workHistoryService from '../services/workHistoryService'; +import { CreateWorkHistoryDto, UpdateWorkHistoryDto, WorkHistoryFilters } from '../types/workHistory'; + +/** + * 작업 이력 목록 조회 + */ +export async function getWorkHistories(req: Request, res: Response): Promise { + try { + const filters: WorkHistoryFilters = { + work_type: req.query.work_type as any, + status: req.query.status as any, + vehicle_number: req.query.vehicle_number as string, + driver_name: req.query.driver_name as string, + start_date: req.query.start_date ? new Date(req.query.start_date as string) : undefined, + end_date: req.query.end_date ? new Date(req.query.end_date as string) : undefined, + search: req.query.search as string, + }; + + const histories = await workHistoryService.getWorkHistories(filters); + res.json({ + success: true, + data: histories, + }); + } catch (error) { + console.error('작업 이력 목록 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 목록 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 단건 조회 + */ +export async function getWorkHistoryById(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + const history = await workHistoryService.getWorkHistoryById(id); + + if (!history) { + res.status(404).json({ + success: false, + message: '작업 이력을 찾을 수 없습니다', + }); + return; + } + + res.json({ + success: true, + data: history, + }); + } catch (error) { + console.error('작업 이력 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 생성 + */ +export async function createWorkHistory(req: Request, res: Response): Promise { + try { + const data: CreateWorkHistoryDto = req.body; + const history = await workHistoryService.createWorkHistory(data); + + res.status(201).json({ + success: true, + data: history, + message: '작업 이력이 생성되었습니다', + }); + } catch (error) { + console.error('작업 이력 생성 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 생성에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 수정 + */ +export async function updateWorkHistory(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + const data: UpdateWorkHistoryDto = req.body; + const history = await workHistoryService.updateWorkHistory(id, data); + + res.json({ + success: true, + data: history, + message: '작업 이력이 수정되었습니다', + }); + } catch (error) { + console.error('작업 이력 수정 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 수정에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 삭제 + */ +export async function deleteWorkHistory(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + await workHistoryService.deleteWorkHistory(id); + + res.json({ + success: true, + message: '작업 이력이 삭제되었습니다', + }); + } catch (error) { + console.error('작업 이력 삭제 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 삭제에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 통계 조회 + */ +export async function getWorkHistoryStats(req: Request, res: Response): Promise { + try { + const stats = await workHistoryService.getWorkHistoryStats(); + res.json({ + success: true, + data: stats, + }); + } catch (error) { + console.error('작업 이력 통계 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 통계 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 월별 추이 조회 + */ +export async function getMonthlyTrend(req: Request, res: Response): Promise { + try { + const months = parseInt(req.query.months as string) || 6; + const trend = await workHistoryService.getMonthlyTrend(months); + res.json({ + success: true, + data: trend, + }); + } catch (error) { + console.error('월별 추이 조회 실패:', error); + res.status(500).json({ + success: false, + message: '월별 추이 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 주요 운송 경로 조회 + */ +export async function getTopRoutes(req: Request, res: Response): Promise { + try { + const limit = parseInt(req.query.limit as string) || 5; + const routes = await workHistoryService.getTopRoutes(limit); + res.json({ + success: true, + data: routes, + }); + } catch (error) { + console.error('주요 운송 경로 조회 실패:', error); + res.status(500).json({ + success: false, + message: '주요 운송 경로 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + diff --git a/backend-node/src/routes/workHistoryRoutes.ts b/backend-node/src/routes/workHistoryRoutes.ts new file mode 100644 index 00000000..330d08db --- /dev/null +++ b/backend-node/src/routes/workHistoryRoutes.ts @@ -0,0 +1,35 @@ +/** + * 작업 이력 관리 라우트 + */ + +import express from 'express'; +import * as workHistoryController from '../controllers/workHistoryController'; + +const router = express.Router(); + +// 작업 이력 목록 조회 +router.get('/', workHistoryController.getWorkHistories); + +// 작업 이력 통계 조회 +router.get('/stats', workHistoryController.getWorkHistoryStats); + +// 월별 추이 조회 +router.get('/trend', workHistoryController.getMonthlyTrend); + +// 주요 운송 경로 조회 +router.get('/routes', workHistoryController.getTopRoutes); + +// 작업 이력 단건 조회 +router.get('/:id', workHistoryController.getWorkHistoryById); + +// 작업 이력 생성 +router.post('/', workHistoryController.createWorkHistory); + +// 작업 이력 수정 +router.put('/:id', workHistoryController.updateWorkHistory); + +// 작업 이력 삭제 +router.delete('/:id', workHistoryController.deleteWorkHistory); + +export default router; + diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 661ffae1..3de082d7 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -53,6 +53,8 @@ const ALLOWED_TABLES = [ "table_labels", "column_labels", "dynamic_form_data", + "work_history", // 작업 이력 테이블 + "delivery_status", // 배송 현황 테이블 ]; /** diff --git a/backend-node/src/services/workHistoryService.ts b/backend-node/src/services/workHistoryService.ts new file mode 100644 index 00000000..5ccceba9 --- /dev/null +++ b/backend-node/src/services/workHistoryService.ts @@ -0,0 +1,335 @@ +/** + * 작업 이력 관리 서비스 + */ + +import pool from '../database/db'; +import { + WorkHistory, + CreateWorkHistoryDto, + UpdateWorkHistoryDto, + WorkHistoryFilters, + WorkHistoryStats, + MonthlyTrend, + TopRoute, +} from '../types/workHistory'; + +/** + * 작업 이력 목록 조회 + */ +export async function getWorkHistories(filters?: WorkHistoryFilters): Promise { + try { + let query = ` + SELECT * FROM work_history + WHERE deleted_at IS NULL + `; + const params: (string | Date)[] = []; + let paramIndex = 1; + + // 필터 적용 + if (filters?.work_type) { + query += ` AND work_type = $${paramIndex}`; + params.push(filters.work_type); + paramIndex++; + } + + if (filters?.status) { + query += ` AND status = $${paramIndex}`; + params.push(filters.status); + paramIndex++; + } + + if (filters?.vehicle_number) { + query += ` AND vehicle_number LIKE $${paramIndex}`; + params.push(`%${filters.vehicle_number}%`); + paramIndex++; + } + + if (filters?.driver_name) { + query += ` AND driver_name LIKE $${paramIndex}`; + params.push(`%${filters.driver_name}%`); + paramIndex++; + } + + if (filters?.start_date) { + query += ` AND work_date >= $${paramIndex}`; + params.push(filters.start_date); + paramIndex++; + } + + if (filters?.end_date) { + query += ` AND work_date <= $${paramIndex}`; + params.push(filters.end_date); + paramIndex++; + } + + if (filters?.search) { + query += ` AND ( + work_number LIKE $${paramIndex} OR + vehicle_number LIKE $${paramIndex} OR + driver_name LIKE $${paramIndex} OR + cargo_name LIKE $${paramIndex} + )`; + params.push(`%${filters.search}%`); + paramIndex++; + } + + query += ` ORDER BY work_date DESC`; + + const result: any = await pool.query(query, params); + return result.rows; + } catch (error) { + console.error('작업 이력 조회 실패:', error); + throw error; + } +} + +/** + * 작업 이력 단건 조회 + */ +export async function getWorkHistoryById(id: number): Promise { + try { + const result: any = await pool.query( + 'SELECT * FROM work_history WHERE id = $1 AND deleted_at IS NULL', + [id] + ); + return result.rows[0] || null; + } catch (error) { + console.error('작업 이력 조회 실패:', error); + throw error; + } +} + +/** + * 작업 이력 생성 + */ +export async function createWorkHistory(data: CreateWorkHistoryDto): Promise { + try { + const result: any = await pool.query( + `INSERT INTO work_history ( + work_type, vehicle_number, driver_name, origin, destination, + cargo_name, cargo_weight, cargo_unit, distance, distance_unit, + status, scheduled_time, estimated_arrival, notes, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING *`, + [ + data.work_type, + data.vehicle_number, + data.driver_name, + data.origin, + data.destination, + data.cargo_name, + data.cargo_weight, + data.cargo_unit || 'ton', + data.distance, + data.distance_unit || 'km', + data.status || 'pending', + data.scheduled_time, + data.estimated_arrival, + data.notes, + data.created_by, + ] + ); + return result.rows[0]; + } catch (error) { + console.error('작업 이력 생성 실패:', error); + throw error; + } +} + +/** + * 작업 이력 수정 + */ +export async function updateWorkHistory(id: number, data: UpdateWorkHistoryDto): Promise { + try { + const fields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + Object.entries(data).forEach(([key, value]) => { + if (value !== undefined) { + fields.push(`${key} = $${paramIndex}`); + values.push(value); + paramIndex++; + } + }); + + if (fields.length === 0) { + throw new Error('수정할 데이터가 없습니다'); + } + + values.push(id); + const query = ` + UPDATE work_history + SET ${fields.join(', ')} + WHERE id = $${paramIndex} AND deleted_at IS NULL + RETURNING * + `; + + const result: any = await pool.query(query, values); + if (result.rows.length === 0) { + throw new Error('작업 이력을 찾을 수 없습니다'); + } + return result.rows[0]; + } catch (error) { + console.error('작업 이력 수정 실패:', error); + throw error; + } +} + +/** + * 작업 이력 삭제 (소프트 삭제) + */ +export async function deleteWorkHistory(id: number): Promise { + try { + const result: any = await pool.query( + 'UPDATE work_history SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1 AND deleted_at IS NULL', + [id] + ); + if (result.rowCount === 0) { + throw new Error('작업 이력을 찾을 수 없습니다'); + } + } catch (error) { + console.error('작업 이력 삭제 실패:', error); + throw error; + } +} + +/** + * 작업 이력 통계 조회 + */ +export async function getWorkHistoryStats(): Promise { + try { + // 오늘 작업 통계 + const todayResult: any = await pool.query(` + SELECT + COUNT(*) as today_total, + COUNT(*) FILTER (WHERE status = 'completed') as today_completed + FROM work_history + WHERE DATE(work_date) = CURRENT_DATE AND deleted_at IS NULL + `); + + // 총 운송량 및 거리 + const totalResult: any = await pool.query(` + SELECT + COALESCE(SUM(cargo_weight), 0) as total_weight, + COALESCE(SUM(distance), 0) as total_distance + FROM work_history + WHERE deleted_at IS NULL AND status = 'completed' + `); + + // 정시 도착률 + const onTimeResult: any = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE is_on_time = true) * 100.0 / NULLIF(COUNT(*), 0) as on_time_rate + FROM work_history + WHERE deleted_at IS NULL + AND status = 'completed' + AND is_on_time IS NOT NULL + `); + + // 작업 유형별 분포 + const typeResult: any = await pool.query(` + SELECT + work_type, + COUNT(*) as count + FROM work_history + WHERE deleted_at IS NULL + GROUP BY work_type + `); + + const typeDistribution = { + inbound: 0, + outbound: 0, + transfer: 0, + maintenance: 0, + }; + + typeResult.rows.forEach((row: any) => { + typeDistribution[row.work_type as keyof typeof typeDistribution] = parseInt(row.count); + }); + + return { + today_total: parseInt(todayResult.rows[0].today_total), + today_completed: parseInt(todayResult.rows[0].today_completed), + total_weight: parseFloat(totalResult.rows[0].total_weight), + total_distance: parseFloat(totalResult.rows[0].total_distance), + on_time_rate: parseFloat(onTimeResult.rows[0]?.on_time_rate || '0'), + type_distribution: typeDistribution, + }; + } catch (error) { + console.error('작업 이력 통계 조회 실패:', error); + throw error; + } +} + +/** + * 월별 추이 조회 + */ +export async function getMonthlyTrend(months: number = 6): Promise { + try { + const result: any = await pool.query( + ` + SELECT + TO_CHAR(work_date, 'YYYY-MM') as month, + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'completed') as completed, + COALESCE(SUM(cargo_weight), 0) as weight, + COALESCE(SUM(distance), 0) as distance + FROM work_history + WHERE deleted_at IS NULL + AND work_date >= CURRENT_DATE - INTERVAL '${months} months' + GROUP BY TO_CHAR(work_date, 'YYYY-MM') + ORDER BY month DESC + `, + [] + ); + + return result.rows.map((row: any) => ({ + month: row.month, + total: parseInt(row.total), + completed: parseInt(row.completed), + weight: parseFloat(row.weight), + distance: parseFloat(row.distance), + })); + } catch (error) { + console.error('월별 추이 조회 실패:', error); + throw error; + } +} + +/** + * 주요 운송 경로 조회 + */ +export async function getTopRoutes(limit: number = 5): Promise { + try { + const result: any = await pool.query( + ` + SELECT + origin, + destination, + COUNT(*) as count, + COALESCE(SUM(cargo_weight), 0) as total_weight + FROM work_history + WHERE deleted_at IS NULL + AND origin IS NOT NULL + AND destination IS NOT NULL + AND work_type IN ('inbound', 'outbound', 'transfer') + GROUP BY origin, destination + ORDER BY count DESC + LIMIT $1 + `, + [limit] + ); + + return result.rows.map((row: any) => ({ + origin: row.origin, + destination: row.destination, + count: parseInt(row.count), + total_weight: parseFloat(row.total_weight), + })); + } catch (error) { + console.error('주요 운송 경로 조회 실패:', error); + throw error; + } +} + diff --git a/backend-node/src/types/workHistory.ts b/backend-node/src/types/workHistory.ts new file mode 100644 index 00000000..83c13fe2 --- /dev/null +++ b/backend-node/src/types/workHistory.ts @@ -0,0 +1,114 @@ +/** + * 작업 이력 관리 타입 정의 + */ + +export type WorkType = 'inbound' | 'outbound' | 'transfer' | 'maintenance'; +export type WorkStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface WorkHistory { + id: number; + work_number: string; + work_date: Date; + work_type: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status: WorkStatus; + scheduled_time?: Date; + start_time?: Date; + end_time?: Date; + estimated_arrival?: Date; + actual_arrival?: Date; + is_on_time?: boolean; + delay_reason?: string; + notes?: string; + created_by?: string; + created_at: Date; + updated_at: Date; + deleted_at?: Date; +} + +export interface CreateWorkHistoryDto { + work_type: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status?: WorkStatus; + scheduled_time?: Date; + estimated_arrival?: Date; + notes?: string; + created_by?: string; +} + +export interface UpdateWorkHistoryDto { + work_type?: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status?: WorkStatus; + scheduled_time?: Date; + start_time?: Date; + end_time?: Date; + estimated_arrival?: Date; + actual_arrival?: Date; + delay_reason?: string; + notes?: string; +} + +export interface WorkHistoryFilters { + work_type?: WorkType; + status?: WorkStatus; + vehicle_number?: string; + driver_name?: string; + start_date?: Date; + end_date?: Date; + search?: string; +} + +export interface WorkHistoryStats { + today_total: number; + today_completed: number; + total_weight: number; + total_distance: number; + on_time_rate: number; + type_distribution: { + inbound: number; + outbound: number; + transfer: number; + maintenance: number; + }; +} + +export interface MonthlyTrend { + month: string; + total: number; + completed: number; + weight: number; + distance: number; +} + +export interface TopRoute { + origin: string; + destination: string; + count: number; + total_weight: number; +} + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 070116f0..66b9d65f 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -112,6 +112,18 @@ const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DW loading: () =>
로딩 중...
, }); +// 작업 이력 위젯 +const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 운송 통계 위젯 +const TransportStatsWidget = dynamic(() => import("@/components/dashboard/widgets/TransportStatsWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; @@ -732,6 +744,16 @@ export function CanvasElement({ }} /> + ) : element.type === "widget" && element.subtype === "work-history" ? ( + // 작업 이력 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "transport-stats" ? ( + // 운송 통계 위젯 렌더링 +
+ +
) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 691da506..f943375f 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -644,6 +644,10 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { return "문서 위젯"; case "yard-management-3d": return "야드 관리 3D"; + case "work-history": + return "작업 이력"; + case "transport-stats": + return "운송 통계"; default: return "위젯"; } @@ -686,6 +690,10 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "list-widget"; case "yard-management-3d": return "yard-3d"; + case "work-history": + return "work-history"; + case "transport-stats": + return "transport-stats"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardSaveModal.tsx b/frontend/components/admin/dashboard/DashboardSaveModal.tsx index 49d6ad7c..28e6e7d7 100644 --- a/frontend/components/admin/dashboard/DashboardSaveModal.tsx +++ b/frontend/components/admin/dashboard/DashboardSaveModal.tsx @@ -183,6 +183,10 @@ export function DashboardSaveModal({ id="title" value={title} onChange={(e) => setTitle(e.target.value)} + onKeyDown={(e) => { + // 모든 키보드 이벤트를 input 필드 내부에서만 처리 + e.stopPropagation(); + }} placeholder="예: 생산 현황 대시보드" className="w-full" /> @@ -195,6 +199,10 @@ export function DashboardSaveModal({ id="description" value={description} onChange={(e) => setDescription(e.target.value)} + onKeyDown={(e) => { + // 모든 키보드 이벤트를 textarea 내부에서만 처리 + e.stopPropagation(); + }} placeholder="대시보드에 대한 간단한 설명을 입력하세요" rows={3} className="w-full resize-none" diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 2f50a874..d11decc1 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -219,6 +219,18 @@ export function DashboardSidebar() { subtype="list" onDragStart={handleDragStart} /> + +
)} diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 35062400..f88933e3 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -182,6 +182,7 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 야드 관리 3D + 운송 통계 {/* 지도 */} 커스텀 지도 카드 {/* 커스텀 목록 카드 */} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index fdfcc2c2..93796257 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -50,7 +50,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element element.subtype === "delivery-today-stats" || element.subtype === "cargo-list" || element.subtype === "customer-issues" || - element.subtype === "driver-management"; + element.subtype === "driver-management" || + element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요) + element.subtype === "transport-stats"; // 운송 통계 위젯 (쿼리 필요) // 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능) const isSelfContainedWidget = diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index e83c9c9b..1335b243 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -208,6 +208,10 @@ ORDER BY 하위부서수 DESC`,