From 0281d3722efe74b8d5f1300699a239056cd222c5 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 1 Dec 2025 18:35:55 +0900 Subject: [PATCH 01/18] =?UTF-8?q?revert:=20SelectBasicComponent.tsx=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../select-basic/SelectBasicComponent.tsx | 137 ++++-------------- 1 file changed, 25 insertions(+), 112 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index eee340d0..0e618b6e 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; -import { createPortal } from "react-dom"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes"; import { cn } from "@/lib/registry/components/common/inputStyles"; @@ -61,8 +60,6 @@ const SelectBasicComponent: React.FC = ({ }); const [isOpen, setIsOpen] = useState(false); - // 드롭다운 위치 (Portal 렌더링용) - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) const config = (props as any).webTypeConfig || componentConfig || {}; @@ -283,26 +280,9 @@ const SelectBasicComponent: React.FC = ({ }, [selectedValue, codeOptions, config.options]); // 클릭 이벤트 핸들러 (React Query로 간소화) - // 드롭다운 위치 계산 함수 - const updateDropdownPosition = () => { - if (selectRef.current) { - const rect = selectRef.current.getBoundingClientRect(); - setDropdownPosition({ - top: rect.bottom + window.scrollY, - left: rect.left + window.scrollX, - width: rect.width, - }); - } - }; - const handleToggle = () => { if (isDesignMode) return; - // 드롭다운 열기 전에 위치 계산 - if (!isOpen) { - updateDropdownPosition(); - } - // React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요 setIsOpen(!isOpen); }; @@ -424,13 +404,9 @@ const SelectBasicComponent: React.FC = ({ value={searchQuery || selectedLabel} onChange={(e) => { setSearchQuery(e.target.value); - updateDropdownPosition(); - setIsOpen(true); - }} - onFocus={() => { - updateDropdownPosition(); setIsOpen(true); }} + onFocus={() => setIsOpen(true)} placeholder="코드 또는 코드명 입력..." className={cn( "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", @@ -439,16 +415,8 @@ const SelectBasicComponent: React.FC = ({ )} readOnly={isDesignMode} /> - {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && filteredOptions.length > 0 && ( +
{filteredOptions.map((option, index) => (
= ({
))} -
, - document.body + )} ); @@ -495,16 +462,8 @@ const SelectBasicComponent: React.FC = ({ - {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && ( +
{isLoadingCodes ? (
로딩 중...
) : allOptions.length > 0 ? ( @@ -520,8 +479,7 @@ const SelectBasicComponent: React.FC = ({ ) : (
옵션이 없습니다
)} -
, - document.body +
)} ); @@ -586,13 +544,9 @@ const SelectBasicComponent: React.FC = ({ value={searchQuery} onChange={(e) => { setSearchQuery(e.target.value); - updateDropdownPosition(); - setIsOpen(true); - }} - onFocus={() => { - updateDropdownPosition(); setIsOpen(true); }} + onFocus={() => setIsOpen(true)} placeholder={placeholder} className={cn( "h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none", @@ -601,16 +555,8 @@ const SelectBasicComponent: React.FC = ({ )} readOnly={isDesignMode} /> - {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && filteredOptions.length > 0 && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && filteredOptions.length > 0 && ( +
{filteredOptions.map((option, index) => (
= ({ {option.label}
))} -
, - document.body +
)} ); @@ -659,16 +604,8 @@ const SelectBasicComponent: React.FC = ({ - {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && ( +
= ({
))}
- , - document.body + )} ); @@ -711,12 +647,7 @@ const SelectBasicComponent: React.FC = ({ !isDesignMode && "hover:border-orange-400", isSelected && "ring-2 ring-orange-500", )} - onClick={() => { - if (!isDesignMode) { - updateDropdownPosition(); - setIsOpen(true); - } - }} + onClick={() => !isDesignMode && setIsOpen(true)} style={{ pointerEvents: isDesignMode ? "none" : "auto", height: "100%" @@ -749,30 +680,22 @@ const SelectBasicComponent: React.FC = ({ {placeholder} )} - {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && ( +
{(isLoadingCodes || isLoadingCategories) ? (
로딩 중...
) : allOptions.length > 0 ? ( allOptions.map((option, index) => { - const isOptionSelected = selectedValues.includes(option.value); + const isSelected = selectedValues.includes(option.value); return (
{ - const newVals = isOptionSelected + const newVals = isSelected ? selectedValues.filter((v) => v !== option.value) : [...selectedValues, option.value]; setSelectedValues(newVals); @@ -785,7 +708,7 @@ const SelectBasicComponent: React.FC = ({
{}} className="h-4 w-4" /> @@ -797,8 +720,7 @@ const SelectBasicComponent: React.FC = ({ ) : (
옵션이 없습니다
)} -
, - document.body +
)}
); @@ -827,16 +749,8 @@ const SelectBasicComponent: React.FC = ({
- {/* Portal을 사용하여 드롭다운을 document.body에 렌더링 */} - {isOpen && !isDesignMode && typeof document !== "undefined" && createPortal( -
+ {isOpen && !isDesignMode && ( +
{isLoadingCodes ? (
로딩 중...
) : allOptions.length > 0 ? ( @@ -852,8 +766,7 @@ const SelectBasicComponent: React.FC = ({ ) : (
옵션이 없습니다
)} -
, - document.body +
)} ); From fb16e224f02e3831455391eb2671abc9055ae802 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 1 Dec 2025 18:39:01 +0900 Subject: [PATCH 02/18] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=A4=91=EA=B0=84=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/ScreenModal.tsx | 10 + .../screen-embedding/ScreenSplitPanel.tsx | 2 + frontend/contexts/SplitPanelContext.tsx | 67 ++++++ .../button-primary/ButtonPrimaryComponent.tsx | 11 + .../card-display/CardDisplayComponent.tsx | 98 +++++---- .../ScreenSplitPanelConfigPanel.tsx | 205 +++++++++++++++++- .../table-list/TableListComponent.tsx | 16 ++ frontend/lib/utils/buttonActions.ts | 12 +- 8 files changed, 374 insertions(+), 47 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 53fd0852..0713c1c3 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -17,6 +17,7 @@ import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; interface ScreenModalState { isOpen: boolean; @@ -32,6 +33,7 @@ interface ScreenModalProps { export const ScreenModal: React.FC = ({ className }) => { const { userId, userName, user } = useAuth(); + const splitPanelContext = useSplitPanelContext(); const [modalState, setModalState] = useState({ isOpen: false, @@ -152,6 +154,14 @@ export const ScreenModal: React.FC = ({ className }) => { setFormData(editData); setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } else { + // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 + const parentData = splitPanelContext?.getMappedParentData() || {}; + if (Object.keys(parentData).length > 0) { + console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData); + setFormData(parentData); + } else { + setFormData({}); + } setOriginalData(null); // 신규 등록 모드 } diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx index 2e43fcc6..4eba4f9b 100644 --- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -33,6 +33,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp leftScreenId: config?.leftScreenId, rightScreenId: config?.rightScreenId, configSplitRatio, + parentDataMapping: config?.parentDataMapping, configKeys: config ? Object.keys(config) : [], }); @@ -125,6 +126,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp splitPanelId={splitPanelId} leftScreenId={config?.leftScreenId || null} rightScreenId={config?.rightScreenId || null} + parentDataMapping={config?.parentDataMapping || []} >
{/* 좌측 패널 */} diff --git a/frontend/contexts/SplitPanelContext.tsx b/frontend/contexts/SplitPanelContext.tsx index bfb9610b..15f3e1f5 100644 --- a/frontend/contexts/SplitPanelContext.tsx +++ b/frontend/contexts/SplitPanelContext.tsx @@ -17,6 +17,15 @@ export interface SplitPanelDataReceiver { receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise; } +/** + * 부모 데이터 매핑 설정 + * 좌측 화면에서 선택한 데이터를 우측 화면 저장 시 자동으로 포함 + */ +export interface ParentDataMapping { + sourceColumn: string; // 좌측 화면의 컬럼명 (예: equipment_code) + targetColumn: string; // 우측 화면 저장 시 사용할 컬럼명 (예: equipment_code) +} + /** * 분할 패널 컨텍스트 값 */ @@ -54,6 +63,16 @@ interface SplitPanelContextValue { addItemIds: (ids: string[]) => void; removeItemIds: (ids: string[]) => void; clearItemIds: () => void; + + // 🆕 좌측 선택 데이터 관리 (우측 화면 저장 시 부모 키 전달용) + selectedLeftData: Record | null; + setSelectedLeftData: (data: Record | null) => void; + + // 🆕 부모 데이터 매핑 설정 + parentDataMapping: ParentDataMapping[]; + + // 🆕 매핑된 부모 데이터 가져오기 (우측 화면 저장 시 사용) + getMappedParentData: () => Record; } const SplitPanelContext = createContext(null); @@ -62,6 +81,7 @@ interface SplitPanelProviderProps { splitPanelId: string; leftScreenId: number | null; rightScreenId: number | null; + parentDataMapping?: ParentDataMapping[]; // 🆕 부모 데이터 매핑 설정 children: React.ReactNode; } @@ -72,6 +92,7 @@ export function SplitPanelProvider({ splitPanelId, leftScreenId, rightScreenId, + parentDataMapping = [], children, }: SplitPanelProviderProps) { // 좌측/우측 화면의 데이터 수신자 맵 @@ -83,6 +104,9 @@ export function SplitPanelProvider({ // 🆕 우측에 추가된 항목 ID 상태 const [addedItemIds, setAddedItemIds] = useState>(new Set()); + + // 🆕 좌측에서 선택된 데이터 상태 + const [selectedLeftData, setSelectedLeftData] = useState | null>(null); /** * 데이터 수신자 등록 @@ -232,6 +256,40 @@ export function SplitPanelProvider({ logger.debug(`[SplitPanelContext] 항목 ID 초기화`); }, []); + /** + * 🆕 좌측 선택 데이터 설정 + */ + const handleSetSelectedLeftData = useCallback((data: Record | null) => { + logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, { + hasData: !!data, + dataKeys: data ? Object.keys(data) : [], + }); + setSelectedLeftData(data); + }, []); + + /** + * 🆕 매핑된 부모 데이터 가져오기 + * 우측 화면에서 저장 시 이 함수를 호출하여 부모 키 값을 가져옴 + */ + const getMappedParentData = useCallback((): Record => { + if (!selectedLeftData || parentDataMapping.length === 0) { + return {}; + } + + const mappedData: Record = {}; + + for (const mapping of parentDataMapping) { + const value = selectedLeftData[mapping.sourceColumn]; + if (value !== undefined && value !== null) { + mappedData[mapping.targetColumn] = value; + logger.debug(`[SplitPanelContext] 부모 데이터 매핑: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${value}`); + } + } + + logger.info(`[SplitPanelContext] 매핑된 부모 데이터:`, mappedData); + return mappedData; + }, [selectedLeftData, parentDataMapping]); + // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) const value = React.useMemo(() => ({ splitPanelId, @@ -247,6 +305,11 @@ export function SplitPanelProvider({ addItemIds, removeItemIds, clearItemIds, + // 🆕 좌측 선택 데이터 관련 + selectedLeftData, + setSelectedLeftData: handleSetSelectedLeftData, + parentDataMapping, + getMappedParentData, }), [ splitPanelId, leftScreenId, @@ -260,6 +323,10 @@ export function SplitPanelProvider({ addItemIds, removeItemIds, clearItemIds, + selectedLeftData, + handleSetSelectedLeftData, + parentDataMapping, + getMappedParentData, ]); return ( diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 180dacaa..564eed1d 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -692,6 +692,15 @@ export const ButtonPrimaryComponent: React.FC = ({ effectiveScreenId, }); + // 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함) + let splitPanelParentData: Record | undefined; + if (splitPanelContext && splitPanelPosition === "right") { + splitPanelParentData = splitPanelContext.getMappedParentData(); + if (Object.keys(splitPanelParentData).length > 0) { + console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", splitPanelParentData); + } + } + const context: ButtonActionContext = { formData: formData || {}, originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) @@ -720,6 +729,8 @@ export const ButtonPrimaryComponent: React.FC = ({ flowSelectedStepId, // 🆕 컴포넌트별 설정 (parentDataMapping 등) componentConfigs, + // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) + splitPanelParentData, } as ButtonActionContext; // 확인이 필요한 액션인지 확인 diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 0912afd7..094ddf70 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -43,6 +43,9 @@ export const CardDisplayComponent: React.FC = ({ const [loadedTableColumns, setLoadedTableColumns] = useState([]); const [loading, setLoading] = useState(false); + // 선택된 카드 상태 + const [selectedCardId, setSelectedCardId] = useState(null); + // 상세보기 모달 상태 const [viewModalOpen, setViewModalOpen] = useState(false); const [selectedData, setSelectedData] = useState(null); @@ -261,26 +264,19 @@ export const CardDisplayComponent: React.FC = ({ borderRadius: "12px", // 컨테이너 자체도 라운드 처리 }; - // 카드 스타일 - 통일된 디자인 시스템 적용 + // 카드 스타일 - 컴팩트한 디자인 const cardStyle: React.CSSProperties = { backgroundColor: "white", - border: "2px solid #e5e7eb", // 더 명확한 테두리 - borderRadius: "12px", // 통일된 라운드 처리 - padding: "24px", // 더 여유로운 패딩 - boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", // 더 깊은 그림자 - transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", // 부드러운 트랜지션 + border: "1px solid #e5e7eb", + borderRadius: "8px", + padding: "16px", + boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)", + transition: "all 0.2s ease", overflow: "hidden", display: "flex", flexDirection: "column", position: "relative", - minHeight: "240px", // 최소 높이 더 증가 cursor: isDesignMode ? "pointer" : "default", - // 호버 효과를 위한 추가 스타일 - "&:hover": { - transform: "translateY(-2px)", - boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)", - borderColor: "#f59e0b", // 호버 시 오렌지 테두리 - } }; // 텍스트 자르기 함수 @@ -328,6 +324,14 @@ export const CardDisplayComponent: React.FC = ({ }; const handleCardClick = (data: any) => { + const cardId = data.id || data.objid || data.ID; + // 이미 선택된 카드를 다시 클릭하면 선택 해제 + if (selectedCardId === cardId) { + setSelectedCardId(null); + } else { + setSelectedCardId(cardId); + } + if (componentConfig.onCardClick) { componentConfig.onCardClick(data); } @@ -421,67 +425,75 @@ export const CardDisplayComponent: React.FC = ({ ? getColumnValue(data, componentConfig.columnMapping.imageColumn) : data.avatar || data.image || ""; + const cardId = data.id || data.objid || data.ID || index; + const isCardSelected = selectedCardId === cardId; + return (
handleCardClick(data)} > - {/* 카드 이미지 - 통일된 디자인 */} + {/* 카드 이미지 */} {componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && ( -
-
- 👤 +
+
+ 👤
)} - {/* 카드 타이틀 - 통일된 디자인 */} - {componentConfig.cardStyle?.showTitle && ( -
-

{titleValue}

+ {/* 카드 타이틀 + 서브타이틀 (가로 배치) */} + {(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && ( +
+ {componentConfig.cardStyle?.showTitle && ( +

{titleValue}

+ )} + {componentConfig.cardStyle?.showSubtitle && subtitleValue && ( + {subtitleValue} + )}
)} - {/* 카드 서브타이틀 - 통일된 디자인 */} - {componentConfig.cardStyle?.showSubtitle && ( -
-

{subtitleValue}

-
- )} - - {/* 카드 설명 - 통일된 디자인 */} + {/* 카드 설명 */} {componentConfig.cardStyle?.showDescription && ( -
-

+

+

{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}

)} - {/* 추가 표시 컬럼들 - 통일된 디자인 */} + {/* 추가 표시 컬럼들 - 가로 배치 */} {componentConfig.columnMapping?.displayColumns && componentConfig.columnMapping.displayColumns.length > 0 && ( -
+
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => { const value = getColumnValue(data, columnName); if (!value) return null; return ( -
- {getColumnLabel(columnName)}: - {value} +
+ {getColumnLabel(columnName)}: + {value}
); })}
)} - {/* 카드 액션 (선택사항) */} -
+ {/* 카드 액션 */} +
+
+ ))} +
+ + {/* 매핑 추가 버튼 */} + + + {/* 안내 메시지 */} +
+

+ 사용 예시: +
+ 좌측: 설비 목록 (equipment_mng) +
+ 우측: 점검항목 추가 화면 +
+
+ 매핑 설정: +
+ - 소스: equipment_code → 타겟: equipment_code +
+
+ 좌측에서 설비를 선택하고 우측에서 점검항목을 추가하면, + 선택한 설비의 equipment_code가 자동으로 저장됩니다. +

+
+ + )} + + + {/* 설정 요약 */} @@ -343,6 +534,14 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl 크기 조절: {localConfig.resizable ? "가능" : "불가능"}
+
+ 데이터 매핑: + + {(localConfig.parentDataMapping || []).length > 0 + ? `${localConfig.parentDataMapping.length}개 설정` + : "미설정"} + +
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 841e6f0a..a643e3a9 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1466,6 +1466,22 @@ export const TableListComponent: React.FC = ({ handleRowSelection(rowKey, !isCurrentlySelected); + // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) + if (splitPanelContext && splitPanelPosition === "left") { + if (!isCurrentlySelected) { + // 선택된 경우: 데이터 저장 + splitPanelContext.setSelectedLeftData(row); + console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", { + row, + parentDataMapping: splitPanelContext.parentDataMapping, + }); + } else { + // 선택 해제된 경우: 데이터 초기화 + splitPanelContext.setSelectedLeftData(null); + console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화"); + } + } + console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index ad441754..2b8864d8 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -215,6 +215,9 @@ export interface ButtonActionContext { // 🆕 컴포넌트별 설정 (parentDataMapping 등) componentConfigs?: Record; // 컴포넌트 ID → 컴포넌트 설정 + + // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) + splitPanelParentData?: Record; } /** @@ -502,8 +505,15 @@ export class ButtonActionExecutor { // console.log("✅ 채번 규칙 할당 완료"); // console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); + // 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터) + const splitPanelData = context.splitPanelParentData || {}; + if (Object.keys(splitPanelData).length > 0) { + console.log("🔗 [handleSave] 분할 패널 부모 데이터 병합:", splitPanelData); + } + const dataWithUserInfo = { - ...formData, + ...splitPanelData, // 분할 패널 부모 데이터 먼저 적용 + ...formData, // 폼 데이터가 우선 (덮어쓰기 가능) writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId created_by: writerValue, // created_by는 항상 로그인한 사람 updated_by: writerValue, // updated_by는 항상 로그인한 사람 From 2f78c83ef632326c3e06a951b25c28b7a5a342ef Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 1 Dec 2025 18:50:26 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(repeat-screen-modal):=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0=EC=9D=B8,?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A7=81,=20CRUD=20=EB=B0=8F=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A7=91=EA=B3=84=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 외부 테이블 데이터 소스 설정 (TableDataSourceConfig) 추가 - 다중 테이블 조인 지원 (AdditionalJoinConfig) - 테이블 필터링 (equals/notEquals) 지원 - 테이블 CRUD (행 추가/수정/삭제) 기능 추가 - 데이터 변경 시 집계 실시간 재계산 (recalculateAggregationsWithExternalData) - 시각적 수식 빌더 (FormulaBuilder) 컴포넌트 추가 - 테이블 컬럼 순서 변경 기능 추가 - 백엔드: 배열 파라미터 IN 절 변환 로직 추가 --- .../src/services/tableManagementService.ts | 20 + frontend/components/screen/ScreenDesigner.tsx | 6 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 8 +- frontend/components/ui/select.tsx | 2 +- .../components/repeat-screen-modal/README.md | 293 +++- .../RepeatScreenModalComponent.tsx | 999 +++++++++++- .../RepeatScreenModalConfigPanel.tsx | 1363 ++++++++++++++++- .../components/repeat-screen-modal/types.ts | 194 ++- .../section-paper/SectionPaperRenderer.tsx | 1 + .../simple-repeater-table/useCalculation.ts | 1 + 10 files changed, 2716 insertions(+), 171 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 173de022..c1748123 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1502,6 +1502,26 @@ export class TableManagementService { columnName ); + // 🆕 배열 처리: IN 절 사용 + if (Array.isArray(value)) { + if (value.length === 0) { + // 빈 배열이면 항상 false 조건 + return { + whereClause: `1 = 0`, + values: [], + paramCount: 0, + }; + } + + // IN 절로 여러 값 검색 + const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", "); + return { + whereClause: `${columnName} IN (${placeholders})`, + values: value, + paramCount: value.length, + }; + } + if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) { // 엔티티 타입이 아니면 기본 검색 return { diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 08199609..95679adc 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -4245,8 +4245,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 통합 패널 */} {panelStates.unified?.isOpen && ( -
-
+
+

패널

-
+
diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index e3940073..8bd98304 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -238,9 +238,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 if (!selectedComponent) { return ( -
+
{/* 해상도 설정과 격자 설정 표시 */} -
+
{/* 해상도 설정 */} {currentResolution && onResolutionChange && ( @@ -1403,7 +1403,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ }; return ( -
+
{/* 헤더 - 간소화 */}
{selectedComponent.type === "widget" && ( @@ -1414,7 +1414,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
{/* 통합 컨텐츠 (탭 제거) */} -
+
{/* 해상도 설정 - 항상 맨 위에 표시 */} {currentResolution && onResolutionChange && ( diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index aef7dd9d..81e90fd3 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -31,7 +31,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} diff --git a/frontend/lib/registry/components/repeat-screen-modal/README.md b/frontend/lib/registry/components/repeat-screen-modal/README.md index 6ba2783a..cb22964d 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/README.md +++ b/frontend/lib/registry/components/repeat-screen-modal/README.md @@ -1,10 +1,63 @@ -# RepeatScreenModal 컴포넌트 v3 +# RepeatScreenModal 컴포넌트 v3.1 ## 개요 `RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다. -## v3 주요 변경사항 +## v3.1 주요 변경사항 (2025-11-28) + +### 1. 외부 테이블 데이터 소스 + +테이블 행에서 **외부 테이블의 데이터를 조회**하여 표시할 수 있습니다. + +``` +예시: 수주 관리에서 출하 계획 이력 조회 +┌─────────────────────────────────────────────────────────────────┐ +│ 카드: 품목 A │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 1] 헤더: 품목코드, 품목명 │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 2] 테이블: shipment_plan 테이블에서 조회 │ +│ → sales_order_id로 조인하여 출하 계획 이력 표시 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2. 테이블 행 CRUD + +테이블 행에서 **행 추가/수정/삭제** 기능을 지원합니다. + +- **추가**: 새 행 추가 버튼으로 빈 행 생성 +- **수정**: 편집 가능한 컬럼 직접 수정 +- **삭제**: 행 삭제 (확인 팝업 옵션) + +### 3. Footer 버튼 영역 + +모달 하단에 **커스터마이징 가능한 버튼 영역**을 제공합니다. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 카드 내용... │ +├─────────────────────────────────────────────────────────────────┤ +│ [초기화] [취소] [저장] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4. 집계 연산식 지원 + +집계 행에서 **컬럼 간 사칙연산**을 지원합니다. + +```typescript +// 예: 미출하 수량 = 수주수량 - 출하수량 +{ + sourceType: "formula", + formula: "{order_qty} - {ship_qty}", + label: "미출하 수량" +} +``` + +--- + +## v3 주요 변경사항 (기존) ### 자유 레이아웃 시스템 @@ -33,29 +86,7 @@ | **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 | | **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 | -### 자유로운 조합 - -``` -예시 1: 헤더 + 집계 + 테이블 (출하계획) -├── [행 1] 헤더: 품목코드, 품목명 -├── [행 2] 집계: 총수주잔량, 현재고 -└── [행 3] 테이블: 수주별 출하계획 - -예시 2: 집계만 -└── [행 1] 집계: 총매출, 총비용, 순이익 - -예시 3: 테이블만 -└── [행 1] 테이블: 품목 목록 - -예시 4: 테이블 2개 -├── [행 1] 테이블: 입고 내역 -└── [행 2] 테이블: 출고 내역 - -예시 5: 헤더 + 헤더 + 필드 -├── [행 1] 헤더: 기본 정보 (읽기전용) -├── [행 2] 헤더: 상세 정보 (읽기전용) -└── [행 3] 필드: 입력 필드 (편집가능) -``` +--- ## 설정 방법 @@ -107,13 +138,34 @@ - **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택 - **스타일**: 배경색, 폰트 크기 -#### 테이블 행 설정 +#### 테이블 행 설정 (v3.1 확장) - **테이블 제목**: 선택사항 - **헤더 표시**: 테이블 헤더 표시 여부 +- **외부 테이블 데이터 소스**: (v3.1 신규) + - 소스 테이블: 조회할 외부 테이블 + - 조인 조건: 외부 테이블 키 ↔ 카드 데이터 키 + - 정렬: 정렬 컬럼 및 방향 +- **CRUD 설정**: (v3.1 신규) + - 추가: 새 행 추가 허용 + - 수정: 행 수정 허용 + - 삭제: 행 삭제 허용 (확인 팝업 옵션) - **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능 - **저장 설정**: 편집 가능한 컬럼의 저장 위치 +### 5. Footer 탭 (v3.1 신규) + +- **Footer 사용**: Footer 영역 활성화 +- **위치**: 컨텐츠 아래 / 하단 고정 (sticky) +- **정렬**: 왼쪽 / 가운데 / 오른쪽 +- **버튼 설정**: + - 라벨: 버튼 텍스트 + - 액션: 저장 / 취소 / 닫기 / 초기화 / 커스텀 + - 스타일: 기본 / 보조 / 외곽선 / 삭제 / 고스트 + - 아이콘: 저장 / X / 초기화 / 없음 + +--- + ## 데이터 흐름 ``` @@ -125,16 +177,22 @@ ↓ 4. 각 그룹에 대해 집계값 계산 ↓ -5. 카드 렌더링 (contentRows 기반) +5. 외부 테이블 데이터 소스가 설정된 테이블 행의 데이터 로드 (v3.1) ↓ -6. 사용자 편집 +6. 카드 렌더링 (contentRows 기반) ↓ -7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장 +7. 사용자 편집 (CRUD 포함) + ↓ +8. Footer 버튼 또는 기본 저장 버튼으로 저장 + ↓ +9. 기본 데이터 + 외부 테이블 데이터 일괄 저장 ``` +--- + ## 사용 예시 -### 출하계획 등록 +### 출하계획 등록 (v3.1 - 외부 테이블 + CRUD) ```typescript { @@ -167,40 +225,185 @@ type: "aggregation", aggregationLayout: "horizontal", aggregationFields: [ - { aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" }, - { aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" } + { sourceType: "aggregation", aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" }, + { sourceType: "formula", formula: "{order_qty} - {ship_qty}", label: "미출하 수량", backgroundColor: "orange" } ] }, { id: "row-3", type: "table", - tableTitle: "수주 목록", + tableTitle: "출하 계획 이력", showTableHeader: true, + // 외부 테이블에서 데이터 조회 + tableDataSource: { + enabled: true, + sourceTable: "shipment_plan", + joinConditions: [ + { sourceKey: "sales_order_id", referenceKey: "id" } + ], + orderBy: { column: "created_date", direction: "desc" } + }, + // CRUD 설정 + tableCrud: { + allowCreate: true, + allowUpdate: true, + allowDelete: true, + newRowDefaults: { + sales_order_id: "{id}", + status: "READY" + }, + deleteConfirm: { enabled: true } + }, tableColumns: [ - { id: "tc1", field: "order_no", label: "수주번호", type: "text", editable: false }, - { id: "tc2", field: "partner_name", label: "거래처", type: "text", editable: false }, - { id: "tc3", field: "balance_qty", label: "미출하", type: "number", editable: false }, - { - id: "tc4", - field: "plan_qty", - label: "출하계획", - type: "number", - editable: true, - targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true } - } + { id: "tc1", field: "plan_date", label: "계획일", type: "date", editable: true }, + { id: "tc2", field: "plan_qty", label: "계획수량", type: "number", editable: true }, + { id: "tc3", field: "status", label: "상태", type: "text", editable: false }, + { id: "tc4", field: "memo", label: "비고", type: "text", editable: true } ] } - ] + ], + // Footer 설정 + footerConfig: { + enabled: true, + position: "sticky", + alignment: "right", + buttons: [ + { id: "btn-cancel", label: "취소", action: "cancel", variant: "outline" }, + { id: "btn-save", label: "저장", action: "save", variant: "default", icon: "save" } + ] + } } ``` +--- + +## 타입 정의 (v3.1) + +### TableDataSourceConfig + +```typescript +interface TableDataSourceConfig { + enabled: boolean; // 외부 데이터 소스 사용 여부 + sourceTable: string; // 조회할 테이블 + joinConditions: JoinCondition[]; // 조인 조건 + orderBy?: { + column: string; // 정렬 컬럼 + direction: "asc" | "desc"; // 정렬 방향 + }; + limit?: number; // 최대 행 수 +} + +interface JoinCondition { + sourceKey: string; // 외부 테이블의 조인 키 + referenceKey: string; // 카드 데이터의 참조 키 + referenceType?: "card" | "row"; // 참조 소스 +} +``` + +### TableCrudConfig + +```typescript +interface TableCrudConfig { + allowCreate: boolean; // 행 추가 허용 + allowUpdate: boolean; // 행 수정 허용 + allowDelete: boolean; // 행 삭제 허용 + newRowDefaults?: Record; // 신규 행 기본값 ({field} 형식 지원) + deleteConfirm?: { + enabled: boolean; // 삭제 확인 팝업 + message?: string; // 확인 메시지 + }; + targetTable?: string; // 저장 대상 테이블 +} +``` + +### FooterConfig + +```typescript +interface FooterConfig { + enabled: boolean; // Footer 사용 여부 + buttons?: FooterButtonConfig[]; + position?: "sticky" | "static"; + alignment?: "left" | "center" | "right"; +} + +interface FooterButtonConfig { + id: string; + label: string; + action: "save" | "cancel" | "close" | "reset" | "custom"; + variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; + icon?: string; + disabled?: boolean; + customAction?: { + type: string; + config?: Record; + }; +} +``` + +### AggregationDisplayConfig (v3.1 확장) + +```typescript +interface AggregationDisplayConfig { + // 값 소스 타입 + sourceType: "aggregation" | "formula" | "external" | "externalFormula"; + + // aggregation: 기존 집계 결과 참조 + aggregationResultField?: string; + + // formula: 컬럼 간 연산 + formula?: string; // 예: "{order_qty} - {ship_qty}" + + // external: 외부 테이블 조회 (향후 구현) + externalSource?: ExternalValueSource; + + // externalFormula: 외부 테이블 + 연산 (향후 구현) + externalSources?: ExternalValueSource[]; + externalFormula?: string; + + // 표시 설정 + label: string; + icon?: string; + backgroundColor?: string; + textColor?: string; + fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; + format?: "number" | "currency" | "percent"; + decimalPlaces?: number; +} +``` + +--- + ## 레거시 호환 v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다. 새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다. +--- + ## 주의사항 1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다. 2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다. 3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다. +4. **외부 테이블 CRUD**: 외부 테이블 데이터 소스가 설정된 테이블에서만 CRUD가 동작합니다. +5. **연산식**: 사칙연산(+, -, *, /)과 괄호만 지원됩니다. 복잡한 함수는 지원하지 않습니다. + +--- + +## 변경 이력 + +### v3.1 (2025-11-28) +- 외부 테이블 데이터 소스 기능 추가 +- 테이블 행 CRUD (추가/수정/삭제) 기능 추가 +- Footer 버튼 영역 기능 추가 +- 집계 연산식 (formula) 지원 추가 +- 다단계 조인 타입 정의 추가 (향후 구현 예정) + +### v3.0 +- 자유 레이아웃 시스템 도입 +- contentRows 기반 행 타입 선택 방식 +- 헤더/필드/집계/테이블 4가지 행 타입 지원 + +### v2.0 +- simple 모드 / withTable 모드 구분 +- cardLayout / tableLayout 분리 diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 997b381c..25807607 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -9,7 +9,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Loader2, Save, X, Layers, Table as TableIcon } from "lucide-react"; +import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { RepeatScreenModalProps, CardData, @@ -20,6 +30,10 @@ import { TableColumnConfig, CardContentRowConfig, AggregationDisplayConfig, + FooterConfig, + FooterButtonConfig, + TableDataSourceConfig, + TableCrudConfig, } from "./types"; import { ComponentRendererProps } from "@/types/component"; import { cn } from "@/lib/utils"; @@ -59,6 +73,9 @@ export function RepeatScreenModalComponent({ // 🆕 v3: 자유 레이아웃 const contentRows = componentConfig?.contentRows || []; + // 🆕 v3.1: Footer 설정 + const footerConfig = componentConfig?.footerConfig; + // (레거시 호환) const cardLayout = componentConfig?.cardLayout || []; const cardMode = componentConfig?.cardMode || "simple"; @@ -71,6 +88,16 @@ export function RepeatScreenModalComponent({ const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [isSaving, setIsSaving] = useState(false); + + // 🆕 v3.1: 외부 테이블 데이터 (테이블 행별로 관리) + const [externalTableData, setExternalTableData] = useState>({}); + // 🆕 v3.1: 삭제 확인 다이얼로그 + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [pendingDeleteInfo, setPendingDeleteInfo] = useState<{ + cardId: string; + rowId: string; + contentRowId: string; + } | null>(null); // 초기 데이터 로드 useEffect(() => { @@ -208,6 +235,425 @@ export function RepeatScreenModalComponent({ loadInitialData(); }, [dataSource, formData, groupedData, contentRows, grouping?.enabled, grouping?.groupByField]); + // 🆕 v3.1: 외부 테이블 데이터 로드 + useEffect(() => { + const loadExternalTableData = async () => { + // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 + const tableRowsWithExternalSource = contentRows.filter( + (row) => row.type === "table" && row.tableDataSource?.enabled + ); + + if (tableRowsWithExternalSource.length === 0) return; + if (groupedCardsData.length === 0 && cardsData.length === 0) return; + + const newExternalData: Record = {}; + + for (const contentRow of tableRowsWithExternalSource) { + const dataSourceConfig = contentRow.tableDataSource!; + const cards = groupedCardsData.length > 0 ? groupedCardsData : cardsData; + + for (const card of cards) { + const cardId = card._cardId; + const representativeData = (card as GroupedCardData)._representativeData || card; + + try { + // 조인 조건 생성 + const filters: Record = {}; + for (const condition of dataSourceConfig.joinConditions) { + const refValue = representativeData[condition.referenceKey]; + if (refValue !== undefined && refValue !== null) { + filters[condition.sourceKey] = refValue; + } + } + + if (Object.keys(filters).length === 0) { + console.warn(`[RepeatScreenModal] 조인 조건이 없습니다: ${contentRow.id}`); + continue; + } + + // API 호출 - 메인 테이블 데이터 + const response = await apiClient.post( + `/table-management/tables/${dataSourceConfig.sourceTable}/data`, + { + search: filters, + page: 1, + size: dataSourceConfig.limit || 100, + sort: dataSourceConfig.orderBy + ? { + column: dataSourceConfig.orderBy.column, + direction: dataSourceConfig.orderBy.direction, + } + : undefined, + } + ); + + if (response.data.success && response.data.data?.data) { + let tableData = response.data.data.data; + + console.log(`[RepeatScreenModal] 소스 테이블 데이터 로드 완료:`, { + sourceTable: dataSourceConfig.sourceTable, + rowCount: tableData.length, + sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], + firstRowData: tableData[0], + }); + + // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 + if (dataSourceConfig.additionalJoins && dataSourceConfig.additionalJoins.length > 0) { + console.log(`[RepeatScreenModal] 조인 설정:`, dataSourceConfig.additionalJoins); + tableData = await loadAndMergeJoinData(tableData, dataSourceConfig.additionalJoins); + console.log(`[RepeatScreenModal] 조인 후 데이터:`, { + rowCount: tableData.length, + sampleRow: tableData[0] ? Object.keys(tableData[0]) : [], + firstRowData: tableData[0], + }); + } + + // 🆕 v3.4: 필터 조건 적용 + if (dataSourceConfig.filterConfig?.enabled) { + const { filterField, filterType, referenceField, referenceSource } = dataSourceConfig.filterConfig; + + // 비교 값 가져오기 + let referenceValue: any; + if (referenceSource === "formData") { + referenceValue = formData?.[referenceField]; + } else { + // representativeData + referenceValue = representativeData[referenceField]; + } + + if (referenceValue !== undefined && referenceValue !== null) { + tableData = tableData.filter((row: any) => { + const rowValue = row[filterField]; + if (filterType === "equals") { + return rowValue === referenceValue; + } else { + // notEquals + return rowValue !== referenceValue; + } + }); + + console.log(`[RepeatScreenModal] 필터 적용: ${filterField} ${filterType} ${referenceValue}, 결과: ${tableData.length}건`); + } + } + + const key = `${cardId}-${contentRow.id}`; + newExternalData[key] = tableData.map((row: any, idx: number) => ({ + _rowId: `ext-row-${cardId}-${contentRow.id}-${idx}-${Date.now()}`, + _originalData: { ...row }, + _isDirty: false, + _isNew: false, + ...row, + })); + } + } catch (error) { + console.error(`[RepeatScreenModal] 외부 테이블 데이터 로드 실패:`, error); + } + } + } + + setExternalTableData((prev) => { + // 이전 데이터와 동일하면 업데이트하지 않음 (무한 루프 방지) + const prevKeys = Object.keys(prev).sort().join(","); + const newKeys = Object.keys(newExternalData).sort().join(","); + if (prevKeys === newKeys) { + // 키가 같으면 데이터 내용 비교 + const isSame = Object.keys(newExternalData).every( + (key) => JSON.stringify(prev[key]) === JSON.stringify(newExternalData[key]) + ); + if (isSame) return prev; + } + + // 🆕 v3.2: 외부 테이블 데이터 로드 후 집계 재계산 + // 비동기적으로 처리하여 무한 루프 방지 + setTimeout(() => { + recalculateAggregationsWithExternalData(newExternalData); + }, 0); + + return newExternalData; + }); + }; + + loadExternalTableData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contentRows, groupedCardsData.length, cardsData.length]); + + // 🆕 v3.3: 추가 조인 테이블 데이터 로드 및 병합 + const loadAndMergeJoinData = async ( + mainData: any[], + additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[] + ): Promise => { + if (mainData.length === 0) return mainData; + + // 각 조인 테이블별로 필요한 키 값들 수집 + for (const joinConfig of additionalJoins) { + if (!joinConfig.joinTable || !joinConfig.sourceKey || !joinConfig.targetKey) continue; + + // 메인 데이터에서 조인 키 값들 추출 + const joinKeyValues = [...new Set(mainData.map((row) => row[joinConfig.sourceKey]).filter(Boolean))]; + + if (joinKeyValues.length === 0) continue; + + try { + // 조인 테이블 데이터 조회 + const joinResponse = await apiClient.post( + `/table-management/tables/${joinConfig.joinTable}/data`, + { + search: { [joinConfig.targetKey]: joinKeyValues }, + page: 1, + size: 1000, // 충분히 큰 값 + } + ); + + if (joinResponse.data.success && joinResponse.data.data?.data) { + const joinData = joinResponse.data.data.data; + + // 조인 데이터를 맵으로 변환 (빠른 조회를 위해) + const joinDataMap = new Map(); + for (const joinRow of joinData) { + joinDataMap.set(joinRow[joinConfig.targetKey], joinRow); + } + + // 메인 데이터에 조인 데이터 병합 + mainData = mainData.map((row) => { + const joinKey = row[joinConfig.sourceKey]; + const joinRow = joinDataMap.get(joinKey); + + if (joinRow) { + // 조인 테이블의 컬럼들을 메인 데이터에 추가 (접두사 없이) + const mergedRow = { ...row }; + for (const [key, value] of Object.entries(joinRow)) { + // 이미 존재하는 키가 아닌 경우에만 추가 (메인 테이블 우선) + if (!(key in mergedRow)) { + mergedRow[key] = value; + } else { + // 충돌하는 경우 조인 테이블명을 접두사로 사용 + mergedRow[`${joinConfig.joinTable}_${key}`] = value; + } + } + return mergedRow; + } + return row; + }); + } + } catch (error) { + console.error(`[RepeatScreenModal] 조인 테이블 데이터 로드 실패 (${joinConfig.joinTable}):`, error); + } + } + + return mainData; + }; + + // 🆕 v3.2: 외부 테이블 데이터가 로드된 후 집계 재계산 + const recalculateAggregationsWithExternalData = (extData: Record) => { + if (!grouping?.aggregations || grouping.aggregations.length === 0) return; + if (groupedCardsData.length === 0) return; + + // 외부 테이블 집계 또는 formula가 있는지 확인 + const hasExternalAggregation = grouping.aggregations.some((agg) => { + const sourceType = agg.sourceType || "column"; + if (sourceType === "formula") return true; // formula는 외부 테이블 참조 가능 + if (sourceType === "column") { + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + return sourceTable && sourceTable !== dataSource?.sourceTable; + } + return false; + }); + + if (!hasExternalAggregation) return; + + // contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기 + const tableRowWithExternalSource = contentRows.find( + (row) => row.type === "table" && row.tableDataSource?.enabled + ); + + if (!tableRowWithExternalSource) return; + + // 각 카드의 집계 재계산 + const updatedCards = groupedCardsData.map((card) => { + const key = `${card._cardId}-${tableRowWithExternalSource.id}`; + const externalRows = extData[key] || []; + + // 집계 재계산 + const newAggregations: Record = {}; + + grouping.aggregations!.forEach((agg) => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + if (isExternalTable) { + // 외부 테이블 집계 + newAggregations[agg.resultField] = calculateColumnAggregation( + externalRows, + agg.sourceField || "", + agg.type || "sum" + ); + } else { + // 기본 테이블 집계 (기존 값 유지) + newAggregations[agg.resultField] = card._aggregations[agg.resultField] || + calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum"); + } + } else if (sourceType === "formula" && agg.formula) { + // 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산 + newAggregations[agg.resultField] = evaluateFormulaWithContext( + agg.formula, + card._representativeData, + card._rows, + externalRows, + newAggregations // 이전 집계 결과 참조 + ); + } + }); + + return { + ...card, + _aggregations: newAggregations, + }; + }); + + // 변경된 경우에만 업데이트 (무한 루프 방지) + setGroupedCardsData((prev) => { + const hasChanges = updatedCards.some((card, idx) => { + const prevCard = prev[idx]; + if (!prevCard) return true; + return JSON.stringify(card._aggregations) !== JSON.stringify(prevCard._aggregations); + }); + return hasChanges ? updatedCards : prev; + }); + }; + + // 🆕 v3.1: 외부 테이블 행 추가 + const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + const key = `${cardId}-${contentRowId}`; + const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId); + const representativeData = (card as GroupedCardData)?._representativeData || card || {}; + + // 기본값 생성 + const newRowData: Record = { + _rowId: `new-row-${Date.now()}`, + _originalData: {}, + _isDirty: true, + _isNew: true, + }; + + // 🆕 v3.5: 카드 대표 데이터에서 조인 테이블 컬럼 값 자동 채우기 + // tableColumns에서 정의된 필드들 중 representativeData에 있는 값을 자동으로 채움 + if (contentRow.tableColumns) { + for (const col of contentRow.tableColumns) { + // representativeData에 해당 필드가 있으면 자동으로 채움 + if (representativeData[col.field] !== undefined && representativeData[col.field] !== null) { + newRowData[col.field] = representativeData[col.field]; + } + } + } + + // 🆕 v3.5: 조인 조건의 키 값도 자동으로 채움 (예: sales_order_id) + if (contentRow.tableDataSource?.joinConditions) { + for (const condition of contentRow.tableDataSource.joinConditions) { + // sourceKey는 소스 테이블(예: shipment_plan)의 컬럼 + // referenceKey는 카드 대표 데이터의 컬럼 (예: id) + const refValue = representativeData[condition.referenceKey]; + if (refValue !== undefined && refValue !== null) { + newRowData[condition.sourceKey] = refValue; + } + } + } + + // newRowDefaults 적용 (사용자 정의 기본값이 우선) + if (contentRow.tableCrud?.newRowDefaults) { + for (const [field, template] of Object.entries(contentRow.tableCrud.newRowDefaults)) { + // {fieldName} 형식의 템플릿 치환 + let value = template; + const matches = template.match(/\{(\w+)\}/g); + if (matches) { + for (const match of matches) { + const fieldName = match.slice(1, -1); + value = value.replace(match, String(representativeData[fieldName] || "")); + } + } + newRowData[field] = value; + } + } + + console.log("[RepeatScreenModal] 새 행 추가:", { + cardId, + contentRowId, + representativeData, + newRowData, + }); + + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: [...(prev[key] || []), newRowData], + }; + + // 🆕 v3.5: 새 행 추가 시 집계 재계산 + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + }; + + // 🆕 v3.1: 외부 테이블 행 삭제 요청 + const handleDeleteExternalRowRequest = (cardId: string, rowId: string, contentRowId: string, contentRow: CardContentRowConfig) => { + if (contentRow.tableCrud?.deleteConfirm?.enabled !== false) { + // 삭제 확인 팝업 표시 + setPendingDeleteInfo({ cardId, rowId, contentRowId }); + setDeleteConfirmOpen(true); + } else { + // 바로 삭제 + handleDeleteExternalRow(cardId, rowId, contentRowId); + } + }; + + // 🆕 v3.1: 외부 테이블 행 삭제 실행 + const handleDeleteExternalRow = (cardId: string, rowId: string, contentRowId: string) => { + const key = `${cardId}-${contentRowId}`; + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: (prev[key] || []).filter((row) => row._rowId !== rowId), + }; + + // 🆕 v3.5: 행 삭제 시 집계 재계산 + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + setDeleteConfirmOpen(false); + setPendingDeleteInfo(null); + }; + + // 🆕 v3.1: 외부 테이블 행 데이터 변경 + const handleExternalRowDataChange = (cardId: string, contentRowId: string, rowId: string, field: string, value: any) => { + const key = `${cardId}-${contentRowId}`; + + // 데이터 업데이트 + setExternalTableData((prev) => { + const newData = { + ...prev, + [key]: (prev[key] || []).map((row) => + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + ), + }; + + // 🆕 v3.5: 데이터 변경 시 집계 실시간 재계산 + // setTimeout으로 비동기 처리하여 상태 업데이트 후 재계산 + setTimeout(() => { + recalculateAggregationsWithExternalData(newData); + }, 0); + + return newData; + }); + }; + // 그룹화된 데이터 처리 const processGroupedData = (data: any[], groupingConfig: typeof grouping): GroupedCardData[] => { if (!groupingConfig?.enabled) { @@ -240,14 +686,6 @@ export function RepeatScreenModalComponent({ let cardIndex = 0; groupMap.forEach((rows, groupKey) => { - // 집계 계산 - const aggregations: Record = {}; - if (groupingConfig.aggregations) { - groupingConfig.aggregations.forEach((agg) => { - aggregations[agg.resultField] = calculateAggregation(rows, agg); - }); - } - // 행 데이터 생성 const cardRows: CardRowData[] = rows.map((row, idx) => ({ _rowId: `row-${cardIndex}-${idx}-${Date.now()}`, @@ -256,13 +694,56 @@ export function RepeatScreenModalComponent({ ...row, })); + const representativeData = rows[0] || {}; + + // 🆕 v3.2: 집계 계산 (순서대로 - 이전 집계 결과 참조 가능) + // 1단계: 기본 테이블 컬럼 집계만 (외부 테이블 데이터는 아직 없음) + const aggregations: Record = {}; + if (groupingConfig.aggregations) { + groupingConfig.aggregations.forEach((agg) => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + // 컬럼 집계 (기본 테이블만 - 외부 테이블은 나중에 처리) + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + if (!isExternalTable) { + // 기본 테이블 집계 + aggregations[agg.resultField] = calculateColumnAggregation( + rows, + agg.sourceField || "", + agg.type || "sum" + ); + } else { + // 외부 테이블 집계는 나중에 계산 (placeholder) + aggregations[agg.resultField] = 0; + } + } else if (sourceType === "formula") { + // 가상 집계 (연산식) - 외부 테이블 없이 먼저 계산 시도 + // 외부 테이블 데이터가 필요한 경우 나중에 재계산됨 + if (agg.formula) { + aggregations[agg.resultField] = evaluateFormulaWithContext( + agg.formula, + representativeData, + rows, + [], // 외부 테이블 데이터 없음 + aggregations // 이전 집계 결과 참조 + ); + } else { + aggregations[agg.resultField] = 0; + } + } + }); + } + result.push({ _cardId: `grouped-card-${cardIndex}-${Date.now()}`, _groupKey: groupKey, _groupField: groupByField || "", _aggregations: aggregations, _rows: cardRows, - _representativeData: rows[0] || {}, + _representativeData: representativeData, }); cardIndex++; @@ -271,11 +752,15 @@ export function RepeatScreenModalComponent({ return result; }; - // 집계 계산 - const calculateAggregation = (rows: any[], agg: AggregationConfig): number => { - const values = rows.map((row) => Number(row[agg.sourceField]) || 0); + // 집계 계산 (컬럼 집계용) + const calculateColumnAggregation = ( + rows: any[], + sourceField: string, + type: "sum" | "count" | "avg" | "min" | "max" + ): number => { + const values = rows.map((row) => Number(row[sourceField]) || 0); - switch (agg.type) { + switch (type) { case "sum": return values.reduce((a, b) => a + b, 0); case "count": @@ -291,6 +776,175 @@ export function RepeatScreenModalComponent({ } }; + // 🆕 v3.2: 집계 계산 (다중 테이블 및 formula 지원) + const calculateAggregation = ( + agg: AggregationConfig, + cardRows: any[], // 기본 테이블 행들 + externalRows: any[], // 외부 테이블 행들 + previousAggregations: Record, // 이전 집계 결과들 + representativeData: Record // 카드 대표 데이터 + ): number => { + const sourceType = agg.sourceType || "column"; + + if (sourceType === "column") { + // 컬럼 집계 + const sourceTable = agg.sourceTable || dataSource?.sourceTable; + const isExternalTable = sourceTable && sourceTable !== dataSource?.sourceTable; + + // 외부 테이블인 경우 externalRows 사용, 아니면 cardRows 사용 + const targetRows = isExternalTable ? externalRows : cardRows; + + return calculateColumnAggregation( + targetRows, + agg.sourceField || "", + agg.type || "sum" + ); + } else if (sourceType === "formula") { + // 가상 집계 (연산식) + if (!agg.formula) return 0; + + return evaluateFormulaWithContext( + agg.formula, + representativeData, + cardRows, + externalRows, + previousAggregations + ); + } + + return 0; + }; + + // 🆕 v3.1: 집계 표시값 계산 (formula, external 등 지원) + const calculateAggregationDisplayValue = ( + aggField: AggregationDisplayConfig, + card: GroupedCardData + ): number | string => { + const sourceType = aggField.sourceType || "aggregation"; + + switch (sourceType) { + case "aggregation": + // 기존 집계 결과 참조 + return card._aggregations?.[aggField.aggregationResultField || ""] || 0; + + case "formula": + // 컬럼 간 연산 + if (!aggField.formula) return 0; + return evaluateFormula(aggField.formula, card._representativeData, card._rows); + + case "external": + // 외부 테이블 값 (별도 로드 필요 - 현재는 placeholder) + // TODO: 외부 테이블 값 로드 구현 + return 0; + + case "externalFormula": + // 외부 테이블 + 연산 (별도 로드 필요 - 현재는 placeholder) + // TODO: 외부 테이블 값 로드 후 연산 구현 + return 0; + + default: + return 0; + } + }; + + // 🆕 v3.2: 연산식 평가 (다중 테이블, 이전 집계 결과 참조 지원) + const evaluateFormulaWithContext = ( + formula: string, + representativeData: Record, + cardRows: any[], // 기본 테이블 행들 + externalRows: any[], // 외부 테이블 행들 + previousAggregations: Record // 이전 집계 결과들 + ): number => { + try { + let expression = formula; + + // 1. 외부 테이블 집계 함수 처리: SUM_EXT({field}), COUNT_EXT({field}) 등 + const extAggFunctions = ["SUM_EXT", "COUNT_EXT", "AVG_EXT", "MIN_EXT", "MAX_EXT"]; + for (const fn of extAggFunctions) { + const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g"); + expression = expression.replace(regex, (match, fieldName) => { + if (!externalRows || externalRows.length === 0) return "0"; + const values = externalRows.map((row) => Number(row[fieldName]) || 0); + const baseFn = fn.replace("_EXT", ""); + switch (baseFn) { + case "SUM": + return String(values.reduce((a, b) => a + b, 0)); + case "COUNT": + return String(values.length); + case "AVG": + return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); + case "MIN": + return String(values.length > 0 ? Math.min(...values) : 0); + case "MAX": + return String(values.length > 0 ? Math.max(...values) : 0); + default: + return "0"; + } + }); + } + + // 2. 기본 테이블 집계 함수 처리: SUM({field}), COUNT({field}) 등 + const aggFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + for (const fn of aggFunctions) { + // SUM_EXT는 이미 처리했으므로 제외 + const regex = new RegExp(`(? { + if (!cardRows || cardRows.length === 0) return "0"; + const values = cardRows.map((row) => Number(row[fieldName]) || 0); + switch (fn) { + case "SUM": + return String(values.reduce((a, b) => a + b, 0)); + case "COUNT": + return String(values.length); + case "AVG": + return String(values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0); + case "MIN": + return String(values.length > 0 ? Math.min(...values) : 0); + case "MAX": + return String(values.length > 0 ? Math.max(...values) : 0); + default: + return "0"; + } + }); + } + + // 3. 단순 필드 참조 치환 (이전 집계 결과 또는 대표 데이터) + const fieldRegex = /\{(\w+)\}/g; + expression = expression.replace(fieldRegex, (match, fieldName) => { + // 먼저 이전 집계 결과에서 찾기 + if (previousAggregations && fieldName in previousAggregations) { + return String(previousAggregations[fieldName]); + } + // 대표 데이터에서 값 가져오기 + const value = representativeData[fieldName]; + return String(Number(value) || 0); + }); + + // 4. 안전한 수식 평가 (사칙연산만 허용) + // 허용 문자: 숫자, 소수점, 사칙연산, 괄호, 공백 + if (!/^[\d\s+\-*/().]+$/.test(expression)) { + console.warn("[RepeatScreenModal] 허용되지 않는 연산식:", expression); + return 0; + } + + // eval 대신 Function 사용 (더 안전) + const result = new Function(`return ${expression}`)(); + return Number(result) || 0; + } catch (error) { + console.error("[RepeatScreenModal] 연산식 평가 실패:", formula, error); + return 0; + } + }; + + // 레거시 호환: 기존 evaluateFormula 유지 + const evaluateFormula = ( + formula: string, + representativeData: Record, + rows?: any[] + ): number => { + return evaluateFormulaWithContext(formula, representativeData, rows || [], [], {}); + }; + // 카드 데이터 로드 (소스 설정에 따라) const loadCardData = async (originalData: any): Promise> => { const cardData: Record = {}; @@ -401,12 +1055,16 @@ export function RepeatScreenModalComponent({ setIsSaving(true); try { + // 기존 데이터 저장 if (cardMode === "withTable") { await saveGroupedData(); } else { await saveSimpleData(); } + // 🆕 v3.1: 외부 테이블 데이터 저장 + await saveExternalTableData(); + alert("저장되었습니다."); } catch (error: any) { console.error("저장 실패:", error); @@ -416,6 +1074,92 @@ export function RepeatScreenModalComponent({ } }; + // 🆕 v3.1: 외부 테이블 데이터 저장 + const saveExternalTableData = async () => { + const savePromises: Promise[] = []; + + for (const [key, rows] of Object.entries(externalTableData)) { + // key 형식: cardId-contentRowId + const [cardId, contentRowId] = key.split("-").slice(0, 2); + const contentRow = contentRows.find((r) => r.id === contentRowId || key.includes(r.id)); + + if (!contentRow?.tableDataSource?.enabled) continue; + + const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; + const dirtyRows = rows.filter((row) => row._isDirty); + + for (const row of dirtyRows) { + const { _rowId, _originalData, _isDirty, _isNew, ...dataToSave } = row; + + if (_isNew) { + // INSERT + savePromises.push( + apiClient.post(`/table-management/tables/${targetTable}/data`, dataToSave).then(() => {}) + ); + } else if (_originalData?.id) { + // UPDATE + savePromises.push( + apiClient.put(`/table-management/tables/${targetTable}/data/${_originalData.id}`, dataToSave).then(() => {}) + ); + } + } + } + + await Promise.all(savePromises); + + // 저장 후 dirty 플래그 초기화 + setExternalTableData((prev) => { + const updated: Record = {}; + for (const [key, rows] of Object.entries(prev)) { + updated[key] = rows.map((row) => ({ + ...row, + _isDirty: false, + _isNew: false, + _originalData: { ...row, _rowId: undefined, _originalData: undefined, _isDirty: undefined, _isNew: undefined }, + })); + } + return updated; + }); + }; + + // 🆕 v3.1: Footer 버튼 클릭 핸들러 + const handleFooterButtonClick = async (btn: FooterButtonConfig) => { + switch (btn.action) { + case "save": + await handleSaveAll(); + break; + case "cancel": + case "close": + // 모달 닫기 이벤트 발생 + window.dispatchEvent(new CustomEvent("closeScreenModal")); + break; + case "reset": + // 데이터 초기화 + if (confirm("변경 사항을 모두 취소하시겠습니까?")) { + // 외부 테이블 데이터 초기화 + setExternalTableData({}); + // 기존 데이터 재로드 + setCardsData([]); + setGroupedCardsData([]); + } + break; + case "custom": + // 커스텀 액션 이벤트 발생 + if (btn.customAction) { + window.dispatchEvent( + new CustomEvent("repeatScreenModalCustomAction", { + detail: { + actionType: btn.customAction.type, + config: btn.customAction.config, + componentId: component?.id, + }, + }) + ); + } + break; + } + }; + // Simple 모드 저장 const saveSimpleData = async () => { const dirtyCards = cardsData.filter((card) => card._isDirty); @@ -536,11 +1280,21 @@ export function RepeatScreenModalComponent({ // 수정 여부 확인 const hasDirtyData = useMemo(() => { + // 기존 데이터 수정 여부 + let hasBaseDirty = false; if (cardMode === "withTable") { - return groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); + hasBaseDirty = groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); + } else { + hasBaseDirty = cardsData.some((c) => c._isDirty); } - return cardsData.some((c) => c._isDirty); - }, [cardMode, cardsData, groupedCardsData]); + + // 🆕 v3.1: 외부 테이블 데이터 수정 여부 + const hasExternalDirty = Object.values(externalTableData).some((rows) => + rows.some((row) => row._isDirty) + ); + + return hasBaseDirty || hasExternalDirty; + }, [cardMode, cardsData, groupedCardsData, externalTableData]); // 디자인 모드 렌더링 if (isDesignMode) { @@ -710,7 +1464,105 @@ export function RepeatScreenModalComponent({ {useNewLayout ? ( contentRows.map((contentRow, rowIndex) => (
- {renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange)} + {contentRow.type === "table" && contentRow.tableDataSource?.enabled ? ( + // 🆕 v3.1: 외부 테이블 데이터 소스 사용 +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} + {contentRow.tableCrud?.allowCreate && ( + + )} +
+ )} + {!contentRow.tableTitle && contentRow.tableCrud?.allowCreate && ( +
+ +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {(contentRow.tableColumns || []).map((col) => ( + + {col.label} + + ))} + {contentRow.tableCrud?.allowDelete && ( + 삭제 + )} + + + )} + + {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + (externalTableData[`${card._cardId}-${contentRow.id}`] || []).map((row) => ( + + {(contentRow.tableColumns || []).map((col) => ( + + {renderTableCell(col, row, (value) => + handleExternalRowDataChange(card._cardId, contentRow.id, row._rowId, col.field, value) + )} + + ))} + {contentRow.tableCrud?.allowDelete && ( + + + + )} + + )) + )} + +
+
+ ) : ( + // 기존 renderContentRow 사용 + renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange) + )}
)) ) : ( @@ -782,20 +1634,74 @@ export function RepeatScreenModalComponent({ ))}
- {/* 저장 버튼 */} - {groupedCardsData.length > 0 && ( -
- + {/* 🆕 v3.1: Footer 버튼 영역 */} + {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( +
+ {footerConfig.buttons.map((btn) => ( + + ))}
- )} + ) : null} {/* 데이터 없음 */} {groupedCardsData.length === 0 && !isLoading && (
표시할 데이터가 없습니다.
)} + + {/* 🆕 v3.1: 삭제 확인 다이얼로그 */} + + + + 삭제 확인 + + 이 행을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + { + if (pendingDeleteInfo) { + handleDeleteExternalRow( + pendingDeleteInfo.cardId, + pendingDeleteInfo.rowId, + pendingDeleteInfo.contentRowId + ); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 삭제 + + + +
); } @@ -852,15 +1758,40 @@ export function RepeatScreenModalComponent({ ))}
- {/* 저장 버튼 */} - {cardsData.length > 0 && ( -
- + {/* 🆕 v3.1: Footer 버튼 영역 */} + {footerConfig?.enabled && footerConfig.buttons && footerConfig.buttons.length > 0 ? ( +
+ {footerConfig.buttons.map((btn) => ( + + ))}
- )} + ) : null} {/* 데이터 없음 */} {cardsData.length === 0 && !isLoading && ( diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index ab8c962d..da7088a9 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -6,17 +6,15 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { Plus, X, GripVertical, Check, ChevronsUpDown, Table, Layers } from "lucide-react"; +import { Plus, X, GripVertical, Check, ChevronsUpDown, Table, Layers, ChevronUp, ChevronDown } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { RepeatScreenModalProps, CardRowConfig, CardColumnConfig, ColumnSourceConfig, ColumnTargetConfig, - DataSourceConfig, GroupingConfig, AggregationConfig, TableLayoutConfig, @@ -84,14 +82,14 @@ function SourceColumnSelector({ variant="outline" role="combobox" aria-expanded={open} - className="h-6 w-full justify-between text-[10px]" + className="h-6 w-full justify-between text-[10px] min-w-0 shrink" disabled={!sourceTable || isLoading} > {isLoading ? "..." : displayText} - + @@ -315,75 +313,583 @@ function CardTitleEditor({ ); } +// 🆕 v3.2: 시각적 수식 빌더 +interface FormulaToken { + id: string; + type: "aggregation" | "column" | "operator" | "number"; + // aggregation: 이전 집계 결과 참조 + aggregationField?: string; + // column: 테이블 컬럼 집계 + table?: string; + column?: string; + aggFunction?: "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "SUM_EXT" | "COUNT_EXT" | "AVG_EXT" | "MIN_EXT" | "MAX_EXT" | "VALUE"; + isExternal?: boolean; + // operator: 연산자 + operator?: "+" | "-" | "*" | "/" | "(" | ")"; + // number: 숫자 + value?: number; +} + +function FormulaBuilder({ + formula, + sourceTable, + allTables, + referenceableAggregations, + onChange, +}: { + formula: string; + sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + referenceableAggregations: AggregationConfig[]; + onChange: (formula: string) => void; +}) { + // 수식 토큰 상태 + const [tokens, setTokens] = useState([]); + + // 새 토큰 추가용 상태 + const [newTokenType, setNewTokenType] = useState<"aggregation" | "column">("aggregation"); + const [newTokenTable, setNewTokenTable] = useState(sourceTable || ""); + const [newTokenColumn, setNewTokenColumn] = useState(""); + const [newTokenAggFunction, setNewTokenAggFunction] = useState("SUM"); + const [newTokenAggField, setNewTokenAggField] = useState(""); + + // formula 문자열에서 토큰 파싱 (초기화용) + useEffect(() => { + if (!formula) { + setTokens([]); + return; + } + + // 간단한 파싱: 기존 formula가 있으면 토큰으로 변환 시도 + const parsed = parseFormulaToTokens(formula, sourceTable); + if (parsed.length > 0) { + setTokens(parsed); + } + }, []); + + // 토큰을 formula 문자열로 변환 + const tokensToFormula = (tokenList: FormulaToken[]): string => { + return tokenList.map((token) => { + switch (token.type) { + case "aggregation": + return `{${token.aggregationField}}`; + case "column": + if (token.aggFunction === "VALUE") { + return `{${token.column}}`; + } + return `${token.aggFunction}({${token.column}})`; + case "operator": + return ` ${token.operator} `; + case "number": + return String(token.value); + default: + return ""; + } + }).join(""); + }; + + // formula 문자열에서 토큰 파싱 (간단한 버전) + const parseFormulaToTokens = (formulaStr: string, defaultTable: string): FormulaToken[] => { + const result: FormulaToken[] = []; + // 간단한 파싱 - 복잡한 경우는 수동 입력 모드로 전환 + // 이 함수는 기존 formula가 있을 때 최대한 파싱 시도 + const parts = formulaStr.split(/(\s*[+\-*/()]\s*)/); + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + + // 연산자 + if (["+", "-", "*", "/", "(", ")"].includes(trimmed)) { + result.push({ + id: `op-${Date.now()}-${Math.random()}`, + type: "operator", + operator: trimmed as FormulaToken["operator"], + }); + continue; + } + + // 집계 함수: SUM({column}), SUM_EXT({column}) + const aggMatch = trimmed.match(/^(SUM|COUNT|AVG|MIN|MAX)(_EXT)?\(\{(\w+)\}\)$/); + if (aggMatch) { + result.push({ + id: `col-${Date.now()}-${Math.random()}`, + type: "column", + table: aggMatch[2] ? "" : defaultTable, // _EXT면 외부 테이블 + column: aggMatch[3], + aggFunction: (aggMatch[1] + (aggMatch[2] || "")) as FormulaToken["aggFunction"], + isExternal: !!aggMatch[2], + }); + continue; + } + + // 필드 참조: {fieldName} + const fieldMatch = trimmed.match(/^\{(\w+)\}$/); + if (fieldMatch) { + result.push({ + id: `agg-${Date.now()}-${Math.random()}`, + type: "aggregation", + aggregationField: fieldMatch[1], + }); + continue; + } + + // 숫자 + const num = parseFloat(trimmed); + if (!isNaN(num)) { + result.push({ + id: `num-${Date.now()}-${Math.random()}`, + type: "number", + value: num, + }); + } + } + + return result; + }; + + // 토큰 추가 + const addToken = (token: FormulaToken) => { + const newTokens = [...tokens, token]; + setTokens(newTokens); + onChange(tokensToFormula(newTokens)); + }; + + // 토큰 삭제 + const removeToken = (tokenId: string) => { + const newTokens = tokens.filter((t) => t.id !== tokenId); + setTokens(newTokens); + onChange(tokensToFormula(newTokens)); + }; + + // 연산자 추가 + const addOperator = (op: FormulaToken["operator"]) => { + addToken({ + id: `op-${Date.now()}`, + type: "operator", + operator: op, + }); + }; + + // 집계 참조 추가 + const addAggregationRef = () => { + if (!newTokenAggField) return; + addToken({ + id: `agg-${Date.now()}`, + type: "aggregation", + aggregationField: newTokenAggField, + }); + setNewTokenAggField(""); + }; + + // 컬럼 집계 추가 + const addColumnAgg = () => { + if (!newTokenColumn) return; + const isExternal = newTokenTable !== sourceTable; + let aggFunc = newTokenAggFunction; + + // 외부 테이블이면 _EXT 붙이기 + if (isExternal && aggFunc && !aggFunc.endsWith("_EXT") && aggFunc !== "VALUE") { + aggFunc = (aggFunc + "_EXT") as FormulaToken["aggFunction"]; + } + + addToken({ + id: `col-${Date.now()}`, + type: "column", + table: newTokenTable, + column: newTokenColumn, + aggFunction: aggFunc, + isExternal, + }); + setNewTokenColumn(""); + }; + + // 토큰 표시 텍스트 + const getTokenDisplay = (token: FormulaToken): string => { + switch (token.type) { + case "aggregation": + const refAgg = referenceableAggregations.find((a) => a.resultField === token.aggregationField); + return refAgg?.label || token.aggregationField || ""; + case "column": + if (token.aggFunction === "VALUE") { + return `${token.column}`; + } + return `${token.aggFunction}(${token.column})`; + case "operator": + return token.operator || ""; + case "number": + return String(token.value); + default: + return ""; + } + }; + + // 토큰 배지 색상 + const getTokenBadgeClass = (token: FormulaToken): string => { + switch (token.type) { + case "aggregation": + return "bg-blue-100 text-blue-700 border-blue-200"; + case "column": + return token.isExternal + ? "bg-orange-100 text-orange-700 border-orange-200" + : "bg-green-100 text-green-700 border-green-200"; + case "operator": + return "bg-gray-100 text-gray-700 border-gray-200"; + case "number": + return "bg-purple-100 text-purple-700 border-purple-200"; + default: + return ""; + } + }; + + return ( +
+ {/* 현재 수식 표시 */} +
+ +
+ {tokens.length === 0 ? ( + 아래에서 요소를 추가하세요 + ) : ( + tokens.map((token) => ( + removeToken(token.id)} + title="클릭하여 삭제" + > + {getTokenDisplay(token)} + + + )) + )} +
+ {/* 생성된 수식 미리보기 */} + {tokens.length > 0 && ( +

+ {tokensToFormula(tokens)} +

+ )} +
+ + {/* 연산자 버튼 */} +
+ +
+ {["+", "-", "*", "/", "(", ")"].map((op) => ( + + ))} +
+
+ + {/* 집계 참조 추가 */} + {referenceableAggregations.length > 0 && ( +
+ +
+ 참조할 집계 선택 +
+ + +
+
+
+ )} + + {/* 테이블 컬럼 집계 추가 */} +
+ + + {/* 테이블 선택 */} +
+ 테이블 + +
+ + {/* 컬럼 선택 */} +
+ 컬럼 + +
+ + {/* 집계 함수 및 추가 버튼 */} +
+
+ 집계 함수 + +
+
+ +
+
+ + {newTokenTable !== sourceTable && newTokenTable && ( +

외부 테이블: _EXT 함수 사용

+ )} +
+ + {/* 수동 입력 모드 토글 */} +
+ + 수동 입력 모드 + +
+ { + const parsed = parseFormulaToTokens(e.target.value, sourceTable); + setTokens(parsed); + onChange(e.target.value); + }} + placeholder="{total_balance} - SUM_EXT({plan_qty})" + className="h-6 text-[10px] font-mono" + /> +

+ 직접 수식 입력. 예: {"{"}resultField{"}"}, SUM({"{"}column{"}"}), SUM_EXT({"{"}column{"}"}) +

+
+
+
+ ); +} + // 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지) +// 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원 function AggregationConfigItem({ agg, index, sourceTable, + allTables, + existingAggregations, onUpdate, onRemove, }: { agg: AggregationConfig; index: number; sourceTable: string; + allTables: { tableName: string; displayName?: string }[]; + existingAggregations: AggregationConfig[]; // 연산식에서 참조할 수 있는 기존 집계들 onUpdate: (updates: Partial) => void; onRemove: () => void; }) { const [localLabel, setLocalLabel] = useState(agg.label || ""); const [localResultField, setLocalResultField] = useState(agg.resultField || ""); + const [localFormula, setLocalFormula] = useState(agg.formula || ""); // agg 변경 시 로컬 상태 동기화 useEffect(() => { setLocalLabel(agg.label || ""); setLocalResultField(agg.resultField || ""); - }, [agg.label, agg.resultField]); + setLocalFormula(agg.formula || ""); + }, [agg.label, agg.resultField, agg.formula]); + + // 현재 집계보다 앞에 정의된 집계들만 참조 가능 (순환 참조 방지) + const referenceableAggregations = existingAggregations.slice(0, index); + + // sourceType 기본값 처리 + const currentSourceType = agg.sourceType || "column"; return ( -
+
- - 집계 {index + 1} - +
+ + {currentSourceType === "formula" ? "가상" : "집계"} {index + 1} + +
-
- - onUpdate({ sourceField: value })} - placeholder="합계할 필드" - /> + {/* 집계 타입 선택 */} +
+ +
-
-
- - -
+ {/* === 컬럼 집계 설정 === */} + {currentSourceType === "column" && ( + <> + {/* 테이블 선택 */} +
+ + +

+ 기본 테이블 외 다른 테이블도 선택 가능 +

+
+ {/* 컬럼 선택 */} +
+ + onUpdate({ sourceField: value })} + placeholder="합계할 필드" + /> +
+ + {/* 집계 함수 */} +
+ + +
+ + )} + + {/* === 가상 집계 (연산식) 설정 === */} + {currentSourceType === "formula" && ( + { + setLocalFormula(newFormula); + onUpdate({ formula: newFormula }); + }} + /> + )} + + {/* 공통: 라벨 및 결과 필드명 */} +
-
-
- - setLocalResultField(e.target.value)} - onBlur={() => onUpdate({ resultField: localResultField })} - onKeyDown={(e) => { - if (e.key === "Enter") { - onUpdate({ resultField: localResultField }); - } - }} - placeholder="total_balance_qty" - className="h-6 text-[10px]" - /> +
+ + setLocalResultField(e.target.value)} + onBlur={() => onUpdate({ resultField: localResultField })} + onKeyDown={(e) => { + if (e.key === "Enter") { + onUpdate({ resultField: localResultField }); + } + }} + placeholder="total_balance_qty" + className="h-6 text-[10px] font-mono" + /> +
); } // 테이블 선택기 (Combobox) - 240px 최적화 -function TableSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) { +function TableSelector({ + value, + onChange, + allTables, + placeholder = "테이블 선택", +}: { + value: string; + onChange: (value: string) => void; + allTables?: { tableName: string; displayName?: string }[]; + placeholder?: string; +}) { const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]); const [isLoading, setIsLoading] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { + // allTables가 전달되면 API 호출 없이 사용 + if (allTables && allTables.length > 0) { + setTables(allTables); + return; + } + const loadTables = async () => { setIsLoading(true); try { const response = await tableManagementApi.getTableList(); if (response.success && response.data) { // API 응답이 배열인 경우와 객체인 경우 모두 처리 - const tableData = Array.isArray(response.data) - ? response.data + const tableData = Array.isArray(response.data) + ? response.data : (response.data as any).tables || response.data || []; setTables(tableData); } @@ -446,10 +968,10 @@ function TableSelector({ value, onChange }: { value: string; onChange: (value: s } }; loadTables(); - }, []); + }, [allTables]); const selectedTable = (tables || []).find((t) => t.tableName === value); - const displayText = selectedTable ? selectedTable.tableName : "테이블 선택"; + const displayText = selectedTable ? selectedTable.tableName : placeholder; return ( @@ -587,10 +1109,19 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM }); }; - const addAggregation = () => { + const addAggregation = (sourceType: "column" | "formula" = "column") => { const newAgg: AggregationConfig = { - sourceField: "", - type: "sum", + sourceType, + // column 타입 기본값 + ...(sourceType === "column" && { + sourceTable: localConfig.dataSource?.sourceTable || "", + sourceField: "", + type: "sum" as const, + }), + // formula 타입 기본값 + ...(sourceType === "formula" && { + formula: "", + }), resultField: `agg_${Date.now()}`, label: "", }; @@ -766,6 +1297,7 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM const addContentRowAggField = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; const newAggField: AggregationDisplayConfig = { + sourceType: "aggregation", aggregationResultField: "", label: "", }; @@ -790,6 +1322,20 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateConfig({ contentRows: newRows }); }; + // 🆕 집계 필드 순서 변경 + const moveContentRowAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => { + const newRows = [...(localConfig.contentRows || [])]; + const fields = newRows[rowIndex].aggregationFields; + if (!fields) return; + + const newIndex = direction === "up" ? fieldIndex - 1 : fieldIndex + 1; + if (newIndex < 0 || newIndex >= fields.length) return; + + // 배열 요소 교환 + [fields[fieldIndex], fields[newIndex]] = [fields[newIndex], fields[fieldIndex]]; + updateConfig({ contentRows: newRows }); + }; + // contentRow 내 테이블 컬럼 관리 (table 타입) const addContentRowTableColumn = (rowIndex: number) => { const newRows = [...(localConfig.contentRows || [])]; @@ -818,6 +1364,23 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM updateConfig({ contentRows: newRows }); }; + // 테이블 컬럼 순서 변경 + const moveContentRowTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => { + const newRows = [...(localConfig.contentRows || [])]; + const columns = newRows[rowIndex].tableColumns; + if (!columns) return; + + const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1; + if (newIndex < 0 || newIndex >= columns.length) return; + + // 컬럼 위치 교환 + const newColumns = [...columns]; + [newColumns[colIndex], newColumns[newIndex]] = [newColumns[newIndex], newColumns[colIndex]]; + newRows[rowIndex].tableColumns = newColumns; + + updateConfig({ contentRows: newRows }); + }; + // === (레거시) Simple 모드 행/컬럼 관련 함수 === const addRow = () => { const newRow: CardRowConfig = { @@ -874,10 +1437,10 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM }; return ( - -
+
+
- + 기본 @@ -1014,8 +1577,8 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM {/* === 그룹핑 설정 탭 === */} - -
+ +

그룹핑

{/* 집계 설정 */} -
+
- +
+ + +
+

+ 컬럼 집계: 테이블 컬럼의 합계/개수 등 | 가상 집계: 연산식으로 계산 +

+ {(localConfig.grouping?.aggregations || []).map((agg, index) => ( updateAggregation(index, updates)} onRemove={() => removeAggregation(index)} /> @@ -1139,9 +1726,11 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM onAddAggField={() => addContentRowAggField(rowIndex)} onRemoveAggField={(fieldIndex) => removeContentRowAggField(rowIndex, fieldIndex)} onUpdateAggField={(fieldIndex, updates) => updateContentRowAggField(rowIndex, fieldIndex, updates)} + onMoveAggField={(fieldIndex, direction) => moveContentRowAggField(rowIndex, fieldIndex, direction)} onAddTableColumn={() => addContentRowTableColumn(rowIndex)} onRemoveTableColumn={(colIndex) => removeContentRowTableColumn(rowIndex, colIndex)} onUpdateTableColumn={(colIndex, updates) => updateContentRowTableColumn(rowIndex, colIndex, updates)} + onMoveTableColumn={(colIndex, direction) => moveContentRowTableColumn(rowIndex, colIndex, direction)} /> ))}
@@ -1155,9 +1744,10 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM )}
+
- +
); } @@ -1177,9 +1767,11 @@ function ContentRowConfigSection({ onAddAggField, onRemoveAggField, onUpdateAggField, + onMoveAggField, onAddTableColumn, onRemoveTableColumn, onUpdateTableColumn, + onMoveTableColumn, }: { row: CardContentRowConfig; rowIndex: number; @@ -1195,9 +1787,11 @@ function ContentRowConfigSection({ onAddAggField: () => void; onRemoveAggField: (fieldIndex: number) => void; onUpdateAggField: (fieldIndex: number, updates: Partial) => void; + onMoveAggField: (fieldIndex: number, direction: "up" | "down") => void; onAddTableColumn: () => void; onRemoveTableColumn: (colIndex: number) => void; onUpdateTableColumn: (colIndex: number, updates: Partial) => void; + onMoveTableColumn?: (colIndex: number, direction: "up" | "down") => void; }) { // 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지) const [localTableTitle, setLocalTableTitle] = useState(row.tableTitle || ""); @@ -1397,9 +1991,34 @@ function ContentRowConfigSection({ {(row.aggregationFields || []).map((field, fieldIndex) => (
- - 집계 {fieldIndex + 1} - +
+ {/* 순서 변경 버튼 */} +
+ + +
+ + 집계 {fieldIndex + 1} + +
@@ -1507,6 +2126,502 @@ function ContentRowConfigSection({
+ {/* 외부 테이블 데이터 소스 설정 */} +
+
+ + onUpdateRow({ + tableDataSource: checked + ? { enabled: true, sourceTable: "", joinConditions: [] } + : undefined, + }) + } + className="scale-[0.6]" + /> + +
+ + {row.tableDataSource?.enabled && ( +
+
+ + +
+ +
+ +
+
+ 외부 테이블 키 + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + joinConditions: [ + { + ...row.tableDataSource?.joinConditions?.[0], + sourceKey: value, + referenceKey: row.tableDataSource?.joinConditions?.[0]?.referenceKey || "", + }, + ], + }, + }) + } + placeholder="키 선택" + /> +
+
+ 카드 데이터 키 + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + joinConditions: [ + { + ...row.tableDataSource?.joinConditions?.[0], + sourceKey: row.tableDataSource?.joinConditions?.[0]?.sourceKey || "", + referenceKey: value, + }, + ], + }, + }) + } + placeholder="키 선택" + /> +
+
+
+ +
+
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + orderBy: value ? { column: value, direction: row.tableDataSource?.orderBy?.direction || "desc" } : undefined, + }, + }) + } + placeholder="선택" + /> +
+
+ + +
+
+ + {/* 🆕 추가 조인 테이블 설정 */} +
+
+ + +
+

+ 소스 테이블에 없는 컬럼을 다른 테이블에서 조인하여 가져옵니다 +

+ + {(row.tableDataSource?.additionalJoins || []).map((join, joinIndex) => ( +
+
+ + 조인 {joinIndex + 1} + + +
+ + {/* 조인 테이블 선택 */} +
+ + { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, joinTable: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="테이블 선택" + /> +
+ + {/* 조인 조건 */} + {join.joinTable && ( +
+ +
+
+ { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, sourceKey: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="소스 키" + /> +
+ = +
+ { + const newJoins = [...(row.tableDataSource?.additionalJoins || [])]; + newJoins[joinIndex] = { ...join, targetKey: value }; + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + additionalJoins: newJoins, + }, + }); + }} + placeholder="조인 키" + /> +
+
+

+ {row.tableDataSource?.sourceTable}.{join.sourceKey || "?"} = {join.joinTable}.{join.targetKey || "?"} +

+
+ )} +
+ ))} + + {(row.tableDataSource?.additionalJoins || []).length === 0 && ( +
+ 조인 테이블 없음 (소스 테이블 컬럼만 사용) +
+ )} +
+ + {/* 🆕 v3.4: 필터 설정 */} +
+
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: checked + ? { + enabled: true, + filterField: "", + filterType: "equals", + referenceField: "", + referenceSource: "representativeData", + } + : undefined, + }, + }) + } + className="scale-[0.6]" + /> +
+

+ 그룹 내 데이터를 특정 조건으로 필터링합니다 (같은 값만 / 다른 값만) +

+ + {row.tableDataSource?.filterConfig?.enabled && ( +
+ {/* 필터 타입 */} +
+ + +
+ + {/* 필터 필드 */} +
+ + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + filterField: value, + }, + }, + }) + } + placeholder="필터링할 컬럼 선택" + /> +

+ 이 컬럼 값을 기준으로 필터링합니다 +

+
+ + {/* 비교 기준 소스 */} +
+ + +
+ + {/* 비교 기준 필드 */} +
+ + {row.tableDataSource.filterConfig.referenceSource === "representativeData" ? ( + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + referenceField: value, + }, + }, + }) + } + placeholder="비교할 필드 선택" + /> + ) : ( + + onUpdateRow({ + tableDataSource: { + ...row.tableDataSource!, + filterConfig: { + ...row.tableDataSource!.filterConfig!, + referenceField: e.target.value, + }, + }, + }) + } + placeholder="formData 필드명 (예: selectedOrderNo)" + className="h-6 text-[10px]" + /> + )} +

+ 이 값과 비교하여 필터링합니다 +

+
+ + {/* 필터 조건 미리보기 */} + {row.tableDataSource.filterConfig.filterField && row.tableDataSource.filterConfig.referenceField && ( +
+ 조건: + {row.tableDataSource.sourceTable}.{row.tableDataSource.filterConfig.filterField} + {row.tableDataSource.filterConfig.filterType === "equals" ? " = " : " != "} + {row.tableDataSource.filterConfig.referenceSource === "representativeData" + ? `카드.${row.tableDataSource.filterConfig.referenceField}` + : `formData.${row.tableDataSource.filterConfig.referenceField}` + } +
+ )} +
+ )} +
+
+ )} +
+ + {/* CRUD 설정 */} +
+ +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false }, + }) + } + className="scale-[0.5]" + /> + +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false }, + }) + } + className="scale-[0.5]" + /> + +
+
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked }, + }) + } + className="scale-[0.5]" + /> + +
+
+ {row.tableCrud?.allowDelete && ( +
+ + onUpdateRow({ + tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } }, + }) + } + className="scale-[0.5]" + /> + +
+ )} +
+ {/* 테이블 컬럼 목록 */}
@@ -1523,9 +2638,14 @@ function ContentRowConfigSection({ col={col} colIndex={colIndex} allTables={allTables} - dataSourceTable={dataSourceTable} + dataSourceTable={row.tableDataSource?.enabled ? row.tableDataSource.sourceTable : dataSourceTable} + additionalJoins={row.tableDataSource?.additionalJoins || []} onUpdate={(updates) => onUpdateTableColumn(colIndex, updates)} onRemove={() => onRemoveTableColumn(colIndex)} + onMoveUp={() => onMoveTableColumn?.(colIndex, "up")} + onMoveDown={() => onMoveTableColumn?.(colIndex, "down")} + isFirst={colIndex === 0} + isLast={colIndex === (row.tableColumns || []).length - 1} /> ))} @@ -1945,20 +3065,42 @@ function TableColumnConfigSection({ colIndex, allTables, dataSourceTable, + additionalJoins, onUpdate, onRemove, + onMoveUp, + onMoveDown, + isFirst, + isLast, }: { col: TableColumnConfig; colIndex: number; allTables: { tableName: string; displayName?: string }[]; dataSourceTable: string; + additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[]; onUpdate: (updates: Partial) => void; onRemove: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + isFirst?: boolean; + isLast?: boolean; }) { // 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지) const [localLabel, setLocalLabel] = useState(col.label || ""); const [localWidth, setLocalWidth] = useState(col.width || ""); + // 선택된 테이블 (소스 테이블 또는 조인 테이블) + const selectedTable = col.fromTable || dataSourceTable; + const selectedJoinId = col.fromJoinId || ""; + + // 사용 가능한 테이블 목록 (소스 테이블 + 조인 테이블들) + const availableTables = [ + { id: "", table: dataSourceTable, label: `${dataSourceTable} (소스)` }, + ...additionalJoins + .filter(j => j.joinTable) + .map(j => ({ id: j.id, table: j.joinTable, label: `${j.joinTable} (조인)` })), + ]; + useEffect(() => { setLocalLabel(col.label || ""); setLocalWidth(col.width || ""); @@ -1979,19 +3121,80 @@ function TableColumnConfigSection({ return (
- - 컬럼 {colIndex + 1} - +
+ {/* 순서 변경 버튼 */} +
+ + +
+ + 컬럼 {colIndex + 1} + + {col.fromJoinId && ( + + 조인 + + )} +
+ {/* 테이블 선택 (조인 테이블이 있을 때만 표시) */} + {additionalJoins.length > 0 && ( +
+ + +
+ )} +
onUpdate({ field: value })} placeholder="필드 선택" diff --git a/frontend/lib/registry/components/repeat-screen-modal/types.ts b/frontend/lib/registry/components/repeat-screen-modal/types.ts index b2c8d861..81a36366 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/types.ts +++ b/frontend/lib/registry/components/repeat-screen-modal/types.ts @@ -23,6 +23,9 @@ export interface RepeatScreenModalProps { // === 🆕 v3: 자유 레이아웃 === contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택) + // === 🆕 v3.1: Footer 버튼 설정 === + footerConfig?: FooterConfig; // Footer 영역 설정 + // === (레거시 호환) === cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장 cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장 @@ -33,6 +36,34 @@ export interface RepeatScreenModalProps { onChange?: (newData: any[]) => void; } +/** + * 🆕 v3.1: Footer 설정 + */ +export interface FooterConfig { + enabled: boolean; // Footer 사용 여부 + buttons?: FooterButtonConfig[]; // Footer 버튼들 + position?: "sticky" | "static"; // sticky: 하단 고정, static: 컨텐츠 아래 + alignment?: "left" | "center" | "right"; // 버튼 정렬 +} + +/** + * 🆕 v3.1: Footer 버튼 설정 + */ +export interface FooterButtonConfig { + id: string; // 버튼 고유 ID + label: string; // 버튼 라벨 + action: "save" | "cancel" | "close" | "reset" | "custom"; // 액션 타입 + variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; // 버튼 스타일 + icon?: string; // 아이콘 (lucide 아이콘명) + disabled?: boolean; // 비활성화 여부 + + // custom 액션일 때 + customAction?: { + type: string; // 커스텀 액션 타입 + config?: Record; // 커스텀 설정 + }; +} + /** * 데이터 소스 설정 */ @@ -79,26 +110,177 @@ export interface CardContentRowConfig { tableTitle?: string; // 테이블 제목 showTableHeader?: boolean; // 테이블 헤더 표시 여부 tableMaxHeight?: string; // 테이블 최대 높이 + + // 🆕 v3.1: 테이블 외부 데이터 소스 + tableDataSource?: TableDataSourceConfig; // 외부 테이블에서 데이터 조회 + + // 🆕 v3.1: 테이블 CRUD 설정 + tableCrud?: TableCrudConfig; // 행 추가/수정/삭제 설정 +} + +/** + * 🆕 v3.1: 테이블 데이터 소스 설정 + * 외부 테이블에서 연관 데이터를 조회 + */ +export interface TableDataSourceConfig { + enabled: boolean; // 외부 데이터 소스 사용 여부 + sourceTable: string; // 조회할 테이블 (예: "shipment_plan") + + // 조인 설정 + joinConditions: JoinCondition[]; // 조인 조건 (복합 키 지원) + + // 🆕 v3.3: 추가 조인 테이블 설정 (소스 테이블에 없는 컬럼 조회) + additionalJoins?: AdditionalJoinConfig[]; + + // 🆕 v3.4: 필터 조건 설정 (그룹 내 특정 조건으로 필터링) + filterConfig?: TableFilterConfig; + + // 정렬 설정 + orderBy?: { + column: string; // 정렬 컬럼 + direction: "asc" | "desc"; // 정렬 방향 + }; + + // 제한 + limit?: number; // 최대 행 수 +} + +/** + * 🆕 v3.4: 테이블 필터 설정 + * 그룹 내 데이터를 특정 조건으로 필터링 + */ +export interface TableFilterConfig { + enabled: boolean; // 필터 사용 여부 + filterField: string; // 필터링할 필드 (예: "order_no") + filterType: "equals" | "notEquals"; // equals: 같은 값만, notEquals: 다른 값만 + referenceField: string; // 비교 기준 필드 (formData 또는 카드 대표 데이터에서) + referenceSource: "formData" | "representativeData"; // 비교 값 소스 +} + +/** + * 🆕 v3.3: 추가 조인 테이블 설정 + * 소스 테이블에서 다른 테이블을 조인하여 컬럼 가져오기 + */ +export interface AdditionalJoinConfig { + id: string; // 조인 설정 고유 ID + joinTable: string; // 조인할 테이블 (예: "sales_order_mng") + joinType: "left" | "inner"; // 조인 타입 + sourceKey: string; // 소스 테이블의 조인 키 (예: "sales_order_id") + targetKey: string; // 조인 테이블의 키 (예: "id") + alias?: string; // 테이블 별칭 (예: "so") + selectColumns?: string[]; // 가져올 컬럼 목록 (비어있으면 전체) +} + +/** + * 🆕 v3.1: 조인 조건 + */ +export interface JoinCondition { + sourceKey: string; // 외부 테이블의 조인 키 (예: "sales_order_id") + referenceKey: string; // 현재 카드 데이터의 참조 키 (예: "id") + referenceType?: "card" | "row"; // card: 카드 대표 데이터, row: 각 행 데이터 (기본: card) +} + +/** + * 🆕 v3.1: 테이블 CRUD 설정 + */ +export interface TableCrudConfig { + allowCreate: boolean; // 행 추가 허용 + allowUpdate: boolean; // 행 수정 허용 + allowDelete: boolean; // 행 삭제 허용 + + // 신규 행 기본값 + newRowDefaults?: Record; // 기본값 (예: { status: "READY", sales_order_id: "{id}" }) + + // 삭제 확인 + deleteConfirm?: { + enabled: boolean; // 삭제 확인 팝업 표시 여부 + message?: string; // 확인 메시지 + }; + + // 저장 대상 테이블 (외부 데이터 소스 사용 시) + targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable) } /** * 🆕 v3: 집계 표시 설정 */ export interface AggregationDisplayConfig { - aggregationResultField: string; // 그룹핑 설정의 resultField 참조 + // 값 소스 타입 + sourceType: "aggregation" | "formula" | "external" | "externalFormula"; + + // === sourceType: "aggregation" (기존 그룹핑 집계 결과 참조) === + aggregationResultField?: string; // 그룹핑 설정의 resultField 참조 + + // === sourceType: "formula" (컬럼 간 연산) === + formula?: string; // 연산식 (예: "{order_qty} - {ship_qty}") + + // === sourceType: "external" (외부 테이블 조회) === + externalSource?: ExternalValueSource; + + // === sourceType: "externalFormula" (외부 테이블 + 연산) === + externalSources?: ExternalValueSource[]; // 여러 외부 소스 + externalFormula?: string; // 외부 값들을 조합한 연산식 (예: "{inv_qty} + {prod_qty}") + + // 표시 설정 label: string; // 표시 라벨 icon?: string; // 아이콘 (lucide 아이콘명) backgroundColor?: string; // 배경색 textColor?: string; // 텍스트 색상 fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기 + format?: "number" | "currency" | "percent"; // 숫자 포맷 + decimalPlaces?: number; // 소수점 자릿수 +} + +/** + * 🆕 v3.1: 외부 값 소스 설정 + */ +export interface ExternalValueSource { + alias: string; // 연산식에서 사용할 별칭 (예: "inv_qty") + sourceTable: string; // 조회할 테이블 + sourceColumn: string; // 조회할 컬럼 + aggregationType?: "sum" | "count" | "avg" | "min" | "max" | "first"; // 집계 타입 (기본: first) + + // 조인 설정 (다단계 조인 지원) + joins: ChainedJoinConfig[]; +} + +/** + * 🆕 v3.1: 다단계 조인 설정 + */ +export interface ChainedJoinConfig { + step: number; // 조인 순서 (1, 2, 3...) + sourceTable: string; // 조인할 테이블 + joinConditions: { + sourceKey: string; // 조인 테이블의 키 + referenceKey: string; // 참조 키 (이전 단계 결과 또는 카드 데이터) + referenceFrom?: "card" | "previousStep"; // 참조 소스 (기본: card, step > 1이면 previousStep) + }[]; + selectColumns?: string[]; // 이 단계에서 선택할 컬럼 } /** * 집계 설정 + * 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원 */ export interface AggregationConfig { - sourceField: string; // 원본 필드 (예: "balance_qty") - type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입 + // === 집계 소스 타입 === + sourceType: "column" | "formula"; // column: 테이블 컬럼 집계, formula: 연산식 (가상 집계) + + // === sourceType: "column" (테이블 컬럼 집계) === + sourceTable?: string; // 집계할 테이블 (기본: dataSource.sourceTable, 외부 테이블도 가능) + sourceField?: string; // 원본 필드 (예: "balance_qty") + type?: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입 + + // === sourceType: "formula" (가상 집계 - 연산식) === + // 연산식 문법: + // - {resultField}: 다른 집계 결과 참조 (예: {total_balance}) + // - {테이블.컬럼}: 테이블의 컬럼 직접 참조 (예: {sales_order_mng.order_qty}) + // - SUM({컬럼}): 기본 테이블 행들의 합계 + // - SUM_EXT({컬럼}): 외부 테이블 행들의 합계 (externalTableData) + // - 산술 연산: +, -, *, /, () + formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})") + + // === 공통 === resultField: string; // 결과 필드명 (예: "total_balance_qty") label: string; // 표시 라벨 (예: "총수주잔량") } @@ -120,7 +302,7 @@ export interface TableLayoutConfig { */ export interface TableColumnConfig { id: string; // 컬럼 고유 ID - field: string; // 필드명 + field: string; // 필드명 (소스 테이블 컬럼 또는 조인 테이블 컬럼) label: string; // 헤더 라벨 type: "text" | "number" | "date" | "select" | "badge"; // 타입 width?: string; // 너비 (예: "100px", "20%") @@ -128,6 +310,10 @@ export interface TableColumnConfig { editable: boolean; // 편집 가능 여부 required?: boolean; // 필수 입력 여부 + // 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시) + fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블) + fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때) + // Select 타입 옵션 selectOptions?: { value: string; label: string }[]; diff --git a/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx b/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx index c4e653e1..fb9dc6fe 100644 --- a/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx +++ b/frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx @@ -25,3 +25,4 @@ if (process.env.NODE_ENV === "development") { SectionPaperRenderer.enableHotReload(); } + diff --git a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts index 1f6f69f2..8a0fdba5 100644 --- a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts +++ b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts @@ -65,3 +65,4 @@ export function useCalculation(calculationRules: CalculationRule[] = []) { }; } + From 8c83db596d5150314bd86c75c397fd566105de06 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 2 Dec 2025 09:53:08 +0900 Subject: [PATCH 04/18] =?UTF-8?q?=EA=B3=B5=EC=B0=A8=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/DashboardController.ts | 24 +- .../controllers/vehicleReportController.ts | 206 ++++++ .../src/controllers/vehicleTripController.ts | 301 ++++++++ backend-node/src/routes/vehicleTripRoutes.ts | 71 ++ backend-node/src/services/riskAlertService.ts | 19 +- .../src/services/vehicleReportService.ts | 403 +++++++++++ .../src/services/vehicleTripService.ts | 456 ++++++++++++ backend-node/src/utils/geoUtils.ts | 176 +++++ .../(admin)/admin/vehicle-reports/page.tsx | 30 + .../app/(admin)/admin/vehicle-trips/page.tsx | 29 + .../config-panels/ButtonConfigPanel.tsx | 188 +---- frontend/components/vehicle/VehicleReport.tsx | 660 ++++++++++++++++++ .../components/vehicle/VehicleTripHistory.tsx | 531 ++++++++++++++ frontend/lib/api/vehicleTrip.ts | 368 ++++++++++ frontend/lib/utils/buttonActions.ts | 205 +++++- 16 files changed, 3469 insertions(+), 200 deletions(-) create mode 100644 backend-node/src/controllers/vehicleReportController.ts create mode 100644 backend-node/src/controllers/vehicleTripController.ts create mode 100644 backend-node/src/routes/vehicleTripRoutes.ts create mode 100644 backend-node/src/services/vehicleReportService.ts create mode 100644 backend-node/src/services/vehicleTripService.ts create mode 100644 backend-node/src/utils/geoUtils.ts create mode 100644 frontend/app/(admin)/admin/vehicle-reports/page.tsx create mode 100644 frontend/app/(admin)/admin/vehicle-trips/page.tsx create mode 100644 frontend/components/vehicle/VehicleReport.tsx create mode 100644 frontend/components/vehicle/VehicleTripHistory.tsx create mode 100644 frontend/lib/api/vehicleTrip.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 87470dd6..104a7fbe 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -72,6 +72,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 +import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -238,6 +239,7 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 +app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 76b666f0..e324c332 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -708,6 +708,12 @@ export class DashboardController { }); } + // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 + const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + if (isKmaApi) { + requestConfig.responseType = 'arraybuffer'; + } + const response = await axios(requestConfig); if (response.status >= 400) { @@ -719,8 +725,24 @@ export class DashboardController { let data = response.data; const contentType = response.headers["content-type"]; + // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) + if (isKmaApi && Buffer.isBuffer(data)) { + const iconv = require('iconv-lite'); + const buffer = Buffer.from(data); + const utf8Text = buffer.toString('utf-8'); + + // UTF-8로 정상 디코딩되었는지 확인 + if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || + (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { + data = { text: utf8Text, contentType, encoding: 'utf-8' }; + } else { + // EUC-KR로 디코딩 + const eucKrText = iconv.decode(buffer, 'EUC-KR'); + data = { text: eucKrText, contentType, encoding: 'euc-kr' }; + } + } // 텍스트 응답인 경우 포맷팅 - if (typeof data === "string") { + else if (typeof data === "string") { data = { text: data, contentType }; } diff --git a/backend-node/src/controllers/vehicleReportController.ts b/backend-node/src/controllers/vehicleReportController.ts new file mode 100644 index 00000000..db17dd24 --- /dev/null +++ b/backend-node/src/controllers/vehicleReportController.ts @@ -0,0 +1,206 @@ +/** + * 차량 운행 리포트 컨트롤러 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { vehicleReportService } from "../services/vehicleReportService"; + +/** + * 일별 통계 조회 + * GET /api/vehicle/reports/daily + */ +export const getDailyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, userId, vehicleId } = req.query; + + console.log("📊 [getDailyReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getDailyReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getDailyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "일별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 주별 통계 조회 + * GET /api/vehicle/reports/weekly + */ +export const getWeeklyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { year, month, userId, vehicleId } = req.query; + + console.log("📊 [getWeeklyReport] 요청:", { companyCode, year, month }); + + const result = await vehicleReportService.getWeeklyReport(companyCode, { + year: year ? parseInt(year as string) : new Date().getFullYear(), + month: month ? parseInt(month as string) : new Date().getMonth() + 1, + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getWeeklyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "주별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 월별 통계 조회 + * GET /api/vehicle/reports/monthly + */ +export const getMonthlyReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { year, userId, vehicleId } = req.query; + + console.log("📊 [getMonthlyReport] 요청:", { companyCode, year }); + + const result = await vehicleReportService.getMonthlyReport(companyCode, { + year: year ? parseInt(year as string) : new Date().getFullYear(), + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getMonthlyReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "월별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 요약 통계 조회 (대시보드용) + * GET /api/vehicle/reports/summary + */ +export const getSummaryReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { period } = req.query; // today, week, month, year + + console.log("📊 [getSummaryReport] 요청:", { companyCode, period }); + + const result = await vehicleReportService.getSummaryReport( + companyCode, + (period as string) || "today" + ); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getSummaryReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "요약 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 운전자별 통계 조회 + * GET /api/vehicle/reports/by-driver + */ +export const getDriverReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, limit } = req.query; + + console.log("📊 [getDriverReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getDriverReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 10, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getDriverReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운전자별 통계 조회에 실패했습니다.", + }); + } +}; + +/** + * 구간별 통계 조회 + * GET /api/vehicle/reports/by-route + */ +export const getRouteReport = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { startDate, endDate, limit } = req.query; + + console.log("📊 [getRouteReport] 요청:", { companyCode, startDate, endDate }); + + const result = await vehicleReportService.getRouteReport(companyCode, { + startDate: startDate as string, + endDate: endDate as string, + limit: limit ? parseInt(limit as string) : 10, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getRouteReport] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "구간별 통계 조회에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/controllers/vehicleTripController.ts b/backend-node/src/controllers/vehicleTripController.ts new file mode 100644 index 00000000..d1604ede --- /dev/null +++ b/backend-node/src/controllers/vehicleTripController.ts @@ -0,0 +1,301 @@ +/** + * 차량 운행 이력 컨트롤러 + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { vehicleTripService } from "../services/vehicleTripService"; + +/** + * 운행 시작 + * POST /api/vehicle/trip/start + */ +export const startTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { vehicleId, departure, arrival, departureName, destinationName, latitude, longitude } = req.body; + + console.log("🚗 [startTrip] 요청:", { userId, companyCode, departure, arrival }); + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.startTrip({ + userId, + companyCode, + vehicleId, + departure, + arrival, + departureName, + destinationName, + latitude, + longitude, + }); + + console.log("✅ [startTrip] 성공:", result); + + res.json({ + success: true, + data: result, + message: "운행이 시작되었습니다.", + }); + } catch (error: any) { + console.error("❌ [startTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 시작에 실패했습니다.", + }); + } +}; + +/** + * 운행 종료 + * POST /api/vehicle/trip/end + */ +export const endTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tripId, latitude, longitude } = req.body; + + console.log("🚗 [endTrip] 요청:", { userId, companyCode, tripId }); + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.endTrip({ + tripId, + userId, + companyCode, + latitude, + longitude, + }); + + console.log("✅ [endTrip] 성공:", result); + + res.json({ + success: true, + data: result, + message: "운행이 종료되었습니다.", + }); + } catch (error: any) { + console.error("❌ [endTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 종료에 실패했습니다.", + }); + } +}; + +/** + * 위치 기록 추가 (연속 추적) + * POST /api/vehicle/trip/location + */ +export const addTripLocation = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + const { tripId, latitude, longitude, accuracy, speed } = req.body; + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + if (latitude === undefined || longitude === undefined) { + return res.status(400).json({ + success: false, + message: "위치 정보(latitude, longitude)가 필요합니다.", + }); + } + + const result = await vehicleTripService.addLocation({ + tripId, + userId, + companyCode, + latitude, + longitude, + accuracy, + speed, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [addTripLocation] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "위치 기록에 실패했습니다.", + }); + } +}; + +/** + * 운행 이력 목록 조회 + * GET /api/vehicle/trips + */ +export const getTripList = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { userId, vehicleId, status, startDate, endDate, departure, arrival, limit, offset } = req.query; + + console.log("🚗 [getTripList] 요청:", { companyCode, userId, status, startDate, endDate }); + + const result = await vehicleTripService.getTripList(companyCode, { + userId: userId as string, + vehicleId: vehicleId ? parseInt(vehicleId as string) : undefined, + status: status as string, + startDate: startDate as string, + endDate: endDate as string, + departure: departure as string, + arrival: arrival as string, + limit: limit ? parseInt(limit as string) : 50, + offset: offset ? parseInt(offset as string) : 0, + }); + + res.json({ + success: true, + data: result.data, + total: result.total, + }); + } catch (error: any) { + console.error("❌ [getTripList] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 이력 조회에 실패했습니다.", + }); + } +}; + +/** + * 운행 상세 조회 (경로 포함) + * GET /api/vehicle/trips/:tripId + */ +export const getTripDetail = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.params; + + console.log("🚗 [getTripDetail] 요청:", { companyCode, tripId }); + + const result = await vehicleTripService.getTripDetail(tripId, companyCode); + + if (!result) { + return res.status(404).json({ + success: false, + message: "운행 정보를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: result, + }); + } catch (error: any) { + console.error("❌ [getTripDetail] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 상세 조회에 실패했습니다.", + }); + } +}; + +/** + * 활성 운행 조회 (현재 진행 중) + * GET /api/vehicle/trip/active + */ +export const getActiveTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, userId } = req.user as any; + + const result = await vehicleTripService.getActiveTrip(userId, companyCode); + + res.json({ + success: true, + data: result, + hasActiveTrip: !!result, + }); + } catch (error: any) { + console.error("❌ [getActiveTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "활성 운행 조회에 실패했습니다.", + }); + } +}; + +/** + * 운행 취소 + * POST /api/vehicle/trip/cancel + */ +export const cancelTrip = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode } = req.user as any; + const { tripId } = req.body; + + if (!tripId) { + return res.status(400).json({ + success: false, + message: "tripId가 필요합니다.", + }); + } + + const result = await vehicleTripService.cancelTrip(tripId, companyCode); + + if (!result) { + return res.status(404).json({ + success: false, + message: "취소할 운행을 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "운행이 취소되었습니다.", + }); + } catch (error: any) { + console.error("❌ [cancelTrip] 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "운행 취소에 실패했습니다.", + }); + } +}; + diff --git a/backend-node/src/routes/vehicleTripRoutes.ts b/backend-node/src/routes/vehicleTripRoutes.ts new file mode 100644 index 00000000..c70a7394 --- /dev/null +++ b/backend-node/src/routes/vehicleTripRoutes.ts @@ -0,0 +1,71 @@ +/** + * 차량 운행 이력 및 리포트 라우트 + */ +import { Router } from "express"; +import { + startTrip, + endTrip, + addTripLocation, + getTripList, + getTripDetail, + getActiveTrip, + cancelTrip, +} from "../controllers/vehicleTripController"; +import { + getDailyReport, + getWeeklyReport, + getMonthlyReport, + getSummaryReport, + getDriverReport, + getRouteReport, +} from "../controllers/vehicleReportController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// === 운행 관리 === +// 운행 시작 +router.post("/trip/start", startTrip); + +// 운행 종료 +router.post("/trip/end", endTrip); + +// 위치 기록 추가 (연속 추적) +router.post("/trip/location", addTripLocation); + +// 활성 운행 조회 (현재 진행 중) +router.get("/trip/active", getActiveTrip); + +// 운행 취소 +router.post("/trip/cancel", cancelTrip); + +// 운행 이력 목록 조회 +router.get("/trips", getTripList); + +// 운행 상세 조회 (경로 포함) +router.get("/trips/:tripId", getTripDetail); + +// === 리포트 === +// 요약 통계 (대시보드용) +router.get("/reports/summary", getSummaryReport); + +// 일별 통계 +router.get("/reports/daily", getDailyReport); + +// 주별 통계 +router.get("/reports/weekly", getWeeklyReport); + +// 월별 통계 +router.get("/reports/monthly", getMonthlyReport); + +// 운전자별 통계 +router.get("/reports/by-driver", getDriverReport); + +// 구간별 통계 +router.get("/reports/by-route", getRouteReport); + +export default router; + diff --git a/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index f3561bbe..03a3fdf1 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -47,9 +47,24 @@ export class RiskAlertService { console.log('✅ 기상청 특보 현황 API 응답 수신 완료'); - // 텍스트 응답 파싱 (EUC-KR 인코딩) + // 텍스트 응답 파싱 (인코딩 자동 감지) const iconv = require('iconv-lite'); - const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR'); + const buffer = Buffer.from(warningResponse.data); + + // UTF-8 먼저 시도, 실패하면 EUC-KR 시도 + let responseText: string; + const utf8Text = buffer.toString('utf-8'); + + // UTF-8로 정상 디코딩되었는지 확인 (한글이 깨지지 않았는지) + if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') || + (utf8Text.includes('#START7777') && !utf8Text.includes('�'))) { + responseText = utf8Text; + console.log('📝 UTF-8 인코딩으로 디코딩'); + } else { + // EUC-KR로 디코딩 + responseText = iconv.decode(buffer, 'EUC-KR'); + console.log('📝 EUC-KR 인코딩으로 디코딩'); + } if (typeof responseText === 'string' && responseText.includes('#START7777')) { const lines = responseText.split('\n'); diff --git a/backend-node/src/services/vehicleReportService.ts b/backend-node/src/services/vehicleReportService.ts new file mode 100644 index 00000000..842dff19 --- /dev/null +++ b/backend-node/src/services/vehicleReportService.ts @@ -0,0 +1,403 @@ +/** + * 차량 운행 리포트 서비스 + */ +import { getPool } from "../database/db"; + +interface DailyReportFilters { + startDate?: string; + endDate?: string; + userId?: string; + vehicleId?: number; +} + +interface WeeklyReportFilters { + year: number; + month: number; + userId?: string; + vehicleId?: number; +} + +interface MonthlyReportFilters { + year: number; + userId?: string; + vehicleId?: number; +} + +interface DriverReportFilters { + startDate?: string; + endDate?: string; + limit?: number; +} + +interface RouteReportFilters { + startDate?: string; + endDate?: string; + limit?: number; +} + +class VehicleReportService { + private get pool() { + return getPool(); + } + + /** + * 일별 통계 조회 + */ + async getDailyReport(companyCode: string, filters: DailyReportFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + // 기본값: 최근 30일 + const endDate = filters.endDate || new Date().toISOString().split("T")[0]; + const startDate = + filters.startDate || + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + + conditions.push(`DATE(start_time) >= $${paramIndex++}`); + params.push(startDate); + conditions.push(`DATE(start_time) <= $${paramIndex++}`); + params.push(endDate); + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + + if (filters.vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(filters.vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + DATE(start_time) as date, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY DATE(start_time) + ORDER BY DATE(start_time) DESC + `; + + const result = await this.pool.query(query, params); + + return { + startDate, + endDate, + data: result.rows.map((row) => ({ + date: row.date, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + cancelledCount: parseInt(row.cancelled_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + })), + }; + } + + /** + * 주별 통계 조회 + */ + async getWeeklyReport(companyCode: string, filters: WeeklyReportFilters) { + const { year, month, userId, vehicleId } = filters; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`); + params.push(year); + conditions.push(`EXTRACT(MONTH FROM start_time) = $${paramIndex++}`); + params.push(month); + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + EXTRACT(WEEK FROM start_time) as week_number, + MIN(DATE(start_time)) as week_start, + MAX(DATE(start_time)) as week_end, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY EXTRACT(WEEK FROM start_time) + ORDER BY week_number + `; + + const result = await this.pool.query(query, params); + + return { + year, + month, + data: result.rows.map((row) => ({ + weekNumber: parseInt(row.week_number), + weekStart: row.week_start, + weekEnd: row.week_end, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + })), + }; + } + + /** + * 월별 통계 조회 + */ + async getMonthlyReport(companyCode: string, filters: MonthlyReportFilters) { + const { year, userId, vehicleId } = filters; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + conditions.push(`EXTRACT(YEAR FROM start_time) = $${paramIndex++}`); + params.push(year); + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(vehicleId); + } + + const whereClause = conditions.join(" AND "); + + const query = ` + SELECT + EXTRACT(MONTH FROM start_time) as month, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration, + COUNT(DISTINCT user_id) as driver_count + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY EXTRACT(MONTH FROM start_time) + ORDER BY month + `; + + const result = await this.pool.query(query, params); + + return { + year, + data: result.rows.map((row) => ({ + month: parseInt(row.month), + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + cancelledCount: parseInt(row.cancelled_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + driverCount: parseInt(row.driver_count), + })), + }; + } + + /** + * 요약 통계 조회 (대시보드용) + */ + async getSummaryReport(companyCode: string, period: string) { + let dateCondition = ""; + + switch (period) { + case "today": + dateCondition = "DATE(start_time) = CURRENT_DATE"; + break; + case "week": + dateCondition = "start_time >= CURRENT_DATE - INTERVAL '7 days'"; + break; + case "month": + dateCondition = "start_time >= CURRENT_DATE - INTERVAL '30 days'"; + break; + case "year": + dateCondition = "EXTRACT(YEAR FROM start_time) = EXTRACT(YEAR FROM CURRENT_DATE)"; + break; + default: + dateCondition = "DATE(start_time) = CURRENT_DATE"; + } + + const query = ` + SELECT + COUNT(*) as total_trips, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_trips, + COUNT(CASE WHEN status = 'active' THEN 1 END) as active_trips, + COUNT(CASE WHEN status = 'cancelled' THEN 1 END) as cancelled_trips, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN status = 'completed' THEN duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration, + COUNT(DISTINCT user_id) as active_drivers + FROM vehicle_trip_summary + WHERE company_code = $1 AND ${dateCondition} + `; + + const result = await this.pool.query(query, [companyCode]); + const row = result.rows[0]; + + // 완료율 계산 + const totalTrips = parseInt(row.total_trips) || 0; + const completedTrips = parseInt(row.completed_trips) || 0; + const completionRate = totalTrips > 0 ? (completedTrips / totalTrips) * 100 : 0; + + return { + period, + totalTrips, + completedTrips, + activeTrips: parseInt(row.active_trips) || 0, + cancelledTrips: parseInt(row.cancelled_trips) || 0, + completionRate: parseFloat(completionRate.toFixed(1)), + totalDistance: parseFloat(row.total_distance) || 0, + totalDuration: parseInt(row.total_duration) || 0, + avgDistance: parseFloat(row.avg_distance) || 0, + avgDuration: parseFloat(row.avg_duration) || 0, + activeDrivers: parseInt(row.active_drivers) || 0, + }; + } + + /** + * 운전자별 통계 조회 + */ + async getDriverReport(companyCode: string, filters: DriverReportFilters) { + const conditions: string[] = ["vts.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`DATE(vts.start_time) >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`DATE(vts.start_time) <= $${paramIndex++}`); + params.push(filters.endDate); + } + + const whereClause = conditions.join(" AND "); + const limit = filters.limit || 10; + + const query = ` + SELECT + vts.user_id, + ui.user_name, + COUNT(*) as trip_count, + COUNT(CASE WHEN vts.status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.total_distance ELSE 0 END), 0) as total_distance, + COALESCE(SUM(CASE WHEN vts.status = 'completed' THEN vts.duration_minutes ELSE 0 END), 0) as total_duration, + COALESCE(AVG(CASE WHEN vts.status = 'completed' THEN vts.total_distance END), 0) as avg_distance + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + WHERE ${whereClause} + GROUP BY vts.user_id, ui.user_name + ORDER BY total_distance DESC + LIMIT $${paramIndex} + `; + + params.push(limit); + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + userId: row.user_id, + userName: row.user_name || row.user_id, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + totalDuration: parseInt(row.total_duration), + avgDistance: parseFloat(row.avg_distance), + })); + } + + /** + * 구간별 통계 조회 + */ + async getRouteReport(companyCode: string, filters: RouteReportFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`DATE(start_time) >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`DATE(start_time) <= $${paramIndex++}`); + params.push(filters.endDate); + } + + // 출발지/도착지가 있는 것만 + conditions.push("departure IS NOT NULL"); + conditions.push("arrival IS NOT NULL"); + + const whereClause = conditions.join(" AND "); + const limit = filters.limit || 10; + + const query = ` + SELECT + departure, + arrival, + departure_name, + destination_name, + COUNT(*) as trip_count, + COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed_count, + COALESCE(SUM(CASE WHEN status = 'completed' THEN total_distance ELSE 0 END), 0) as total_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN total_distance END), 0) as avg_distance, + COALESCE(AVG(CASE WHEN status = 'completed' THEN duration_minutes END), 0) as avg_duration + FROM vehicle_trip_summary + WHERE ${whereClause} + GROUP BY departure, arrival, departure_name, destination_name + ORDER BY trip_count DESC + LIMIT $${paramIndex} + `; + + params.push(limit); + const result = await this.pool.query(query, params); + + return result.rows.map((row) => ({ + departure: row.departure, + arrival: row.arrival, + departureName: row.departure_name || row.departure, + destinationName: row.destination_name || row.arrival, + tripCount: parseInt(row.trip_count), + completedCount: parseInt(row.completed_count), + totalDistance: parseFloat(row.total_distance), + avgDistance: parseFloat(row.avg_distance), + avgDuration: parseFloat(row.avg_duration), + })); + } +} + +export const vehicleReportService = new VehicleReportService(); + diff --git a/backend-node/src/services/vehicleTripService.ts b/backend-node/src/services/vehicleTripService.ts new file mode 100644 index 00000000..ee640e24 --- /dev/null +++ b/backend-node/src/services/vehicleTripService.ts @@ -0,0 +1,456 @@ +/** + * 차량 운행 이력 서비스 + */ +import { getPool } from "../database/db"; +import { v4 as uuidv4 } from "uuid"; +import { calculateDistance } from "../utils/geoUtils"; + +interface StartTripParams { + userId: string; + companyCode: string; + vehicleId?: number; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + latitude: number; + longitude: number; +} + +interface EndTripParams { + tripId: string; + userId: string; + companyCode: string; + latitude: number; + longitude: number; +} + +interface AddLocationParams { + tripId: string; + userId: string; + companyCode: string; + latitude: number; + longitude: number; + accuracy?: number; + speed?: number; +} + +interface TripListFilters { + userId?: string; + vehicleId?: number; + status?: string; + startDate?: string; + endDate?: string; + departure?: string; + arrival?: string; + limit?: number; + offset?: number; +} + +class VehicleTripService { + private get pool() { + return getPool(); + } + + /** + * 운행 시작 + */ + async startTrip(params: StartTripParams) { + const { + userId, + companyCode, + vehicleId, + departure, + arrival, + departureName, + destinationName, + latitude, + longitude, + } = params; + + const tripId = `TRIP-${Date.now()}-${uuidv4().substring(0, 8)}`; + + // 1. vehicle_trip_summary에 운행 기록 생성 + const summaryQuery = ` + INSERT INTO vehicle_trip_summary ( + trip_id, user_id, vehicle_id, departure, arrival, + departure_name, destination_name, start_time, status, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'active', $8) + RETURNING * + `; + + const summaryResult = await this.pool.query(summaryQuery, [ + tripId, + userId, + vehicleId || null, + departure || null, + arrival || null, + departureName || null, + destinationName || null, + companyCode, + ]); + + // 2. 시작 위치 기록 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, 'start', $6, $7, $8, $9, NOW(), $10) + RETURNING id + `; + + await this.pool.query(locationQuery, [ + tripId, + userId, + vehicleId || null, + latitude, + longitude, + departure || null, + arrival || null, + departureName || null, + destinationName || null, + companyCode, + ]); + + return { + tripId, + summary: summaryResult.rows[0], + startLocation: { latitude, longitude }, + }; + } + + /** + * 운행 종료 + */ + async endTrip(params: EndTripParams) { + const { tripId, userId, companyCode, latitude, longitude } = params; + + // 1. 운행 정보 조회 + const tripQuery = ` + SELECT * FROM vehicle_trip_summary + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + `; + const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]); + + if (tripResult.rows.length === 0) { + throw new Error("활성 운행을 찾을 수 없습니다."); + } + + const trip = tripResult.rows[0]; + + // 2. 마지막 위치 기록 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, 'end', $6, $7, $8, $9, NOW(), $10) + RETURNING id + `; + + await this.pool.query(locationQuery, [ + tripId, + userId, + trip.vehicle_id, + latitude, + longitude, + trip.departure, + trip.arrival, + trip.departure_name, + trip.destination_name, + companyCode, + ]); + + // 3. 총 거리 및 위치 수 계산 + const statsQuery = ` + SELECT + COUNT(*) as location_count, + MIN(recorded_at) as start_time, + MAX(recorded_at) as end_time + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + `; + const statsResult = await this.pool.query(statsQuery, [tripId, companyCode]); + const stats = statsResult.rows[0]; + + // 4. 모든 위치 데이터로 총 거리 계산 + const locationsQuery = ` + SELECT latitude, longitude + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at ASC + `; + const locationsResult = await this.pool.query(locationsQuery, [tripId, companyCode]); + + let totalDistance = 0; + const locations = locationsResult.rows; + for (let i = 1; i < locations.length; i++) { + const prev = locations[i - 1]; + const curr = locations[i]; + totalDistance += calculateDistance( + prev.latitude, + prev.longitude, + curr.latitude, + curr.longitude + ); + } + + // 5. 운행 시간 계산 (분) + const startTime = new Date(stats.start_time); + const endTime = new Date(stats.end_time); + const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / 60000); + + // 6. 운행 요약 업데이트 + const updateQuery = ` + UPDATE vehicle_trip_summary + SET + end_time = NOW(), + total_distance = $1, + duration_minutes = $2, + location_count = $3, + status = 'completed' + WHERE trip_id = $4 AND company_code = $5 + RETURNING * + `; + + const updateResult = await this.pool.query(updateQuery, [ + totalDistance.toFixed(3), + durationMinutes, + stats.location_count, + tripId, + companyCode, + ]); + + return { + tripId, + summary: updateResult.rows[0], + totalDistance: parseFloat(totalDistance.toFixed(3)), + durationMinutes, + locationCount: parseInt(stats.location_count), + }; + } + + /** + * 위치 기록 추가 (연속 추적) + */ + async addLocation(params: AddLocationParams) { + const { tripId, userId, companyCode, latitude, longitude, accuracy, speed } = params; + + // 1. 운행 정보 조회 + const tripQuery = ` + SELECT * FROM vehicle_trip_summary + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + `; + const tripResult = await this.pool.query(tripQuery, [tripId, companyCode]); + + if (tripResult.rows.length === 0) { + throw new Error("활성 운행을 찾을 수 없습니다."); + } + + const trip = tripResult.rows[0]; + + // 2. 이전 위치 조회 (거리 계산용) + const prevLocationQuery = ` + SELECT latitude, longitude + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at DESC + LIMIT 1 + `; + const prevResult = await this.pool.query(prevLocationQuery, [tripId, companyCode]); + + let distanceFromPrev = 0; + if (prevResult.rows.length > 0) { + const prev = prevResult.rows[0]; + distanceFromPrev = calculateDistance( + prev.latitude, + prev.longitude, + latitude, + longitude + ); + } + + // 3. 위치 기록 추가 + const locationQuery = ` + INSERT INTO vehicle_location_history ( + trip_id, user_id, vehicle_id, latitude, longitude, + accuracy, speed, distance_from_prev, + trip_status, departure, arrival, departure_name, destination_name, + recorded_at, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'tracking', $9, $10, $11, $12, NOW(), $13) + RETURNING id + `; + + const result = await this.pool.query(locationQuery, [ + tripId, + userId, + trip.vehicle_id, + latitude, + longitude, + accuracy || null, + speed || null, + distanceFromPrev > 0 ? distanceFromPrev.toFixed(3) : null, + trip.departure, + trip.arrival, + trip.departure_name, + trip.destination_name, + companyCode, + ]); + + // 4. 운행 요약의 위치 수 업데이트 + await this.pool.query( + `UPDATE vehicle_trip_summary SET location_count = location_count + 1 WHERE trip_id = $1`, + [tripId] + ); + + return { + locationId: result.rows[0].id, + distanceFromPrev: parseFloat(distanceFromPrev.toFixed(3)), + }; + } + + /** + * 운행 이력 목록 조회 + */ + async getTripList(companyCode: string, filters: TripListFilters) { + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + + if (filters.vehicleId) { + conditions.push(`vehicle_id = $${paramIndex++}`); + params.push(filters.vehicleId); + } + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + + if (filters.startDate) { + conditions.push(`start_time >= $${paramIndex++}`); + params.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`start_time <= $${paramIndex++}`); + params.push(filters.endDate + " 23:59:59"); + } + + if (filters.departure) { + conditions.push(`departure = $${paramIndex++}`); + params.push(filters.departure); + } + + if (filters.arrival) { + conditions.push(`arrival = $${paramIndex++}`); + params.push(filters.arrival); + } + + const whereClause = conditions.join(" AND "); + + // 총 개수 조회 + const countQuery = `SELECT COUNT(*) as total FROM vehicle_trip_summary WHERE ${whereClause}`; + const countResult = await this.pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // 목록 조회 + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + const listQuery = ` + SELECT + vts.*, + ui.user_name, + v.vehicle_number + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + LEFT JOIN vehicles v ON vts.vehicle_id = v.id + WHERE ${whereClause} + ORDER BY vts.start_time DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++} + `; + + params.push(limit, offset); + const listResult = await this.pool.query(listQuery, params); + + return { + data: listResult.rows, + total, + }; + } + + /** + * 운행 상세 조회 (경로 포함) + */ + async getTripDetail(tripId: string, companyCode: string) { + // 1. 운행 요약 조회 + const summaryQuery = ` + SELECT + vts.*, + ui.user_name, + v.vehicle_number + FROM vehicle_trip_summary vts + LEFT JOIN user_info ui ON vts.user_id = ui.user_id + LEFT JOIN vehicles v ON vts.vehicle_id = v.id + WHERE vts.trip_id = $1 AND vts.company_code = $2 + `; + const summaryResult = await this.pool.query(summaryQuery, [tripId, companyCode]); + + if (summaryResult.rows.length === 0) { + return null; + } + + // 2. 경로 데이터 조회 + const routeQuery = ` + SELECT + id, latitude, longitude, accuracy, speed, + distance_from_prev, trip_status, recorded_at + FROM vehicle_location_history + WHERE trip_id = $1 AND company_code = $2 + ORDER BY recorded_at ASC + `; + const routeResult = await this.pool.query(routeQuery, [tripId, companyCode]); + + return { + summary: summaryResult.rows[0], + route: routeResult.rows, + }; + } + + /** + * 활성 운행 조회 + */ + async getActiveTrip(userId: string, companyCode: string) { + const query = ` + SELECT * FROM vehicle_trip_summary + WHERE user_id = $1 AND company_code = $2 AND status = 'active' + ORDER BY start_time DESC + LIMIT 1 + `; + const result = await this.pool.query(query, [userId, companyCode]); + return result.rows[0] || null; + } + + /** + * 운행 취소 + */ + async cancelTrip(tripId: string, companyCode: string) { + const query = ` + UPDATE vehicle_trip_summary + SET status = 'cancelled', end_time = NOW() + WHERE trip_id = $1 AND company_code = $2 AND status = 'active' + RETURNING * + `; + const result = await this.pool.query(query, [tripId, companyCode]); + return result.rows[0] || null; + } +} + +export const vehicleTripService = new VehicleTripService(); diff --git a/backend-node/src/utils/geoUtils.ts b/backend-node/src/utils/geoUtils.ts new file mode 100644 index 00000000..50f370ad --- /dev/null +++ b/backend-node/src/utils/geoUtils.ts @@ -0,0 +1,176 @@ +/** + * 지리 좌표 관련 유틸리티 함수 + */ + +/** + * Haversine 공식을 사용하여 두 좌표 간의 거리 계산 (km) + * + * @param lat1 - 첫 번째 지점의 위도 + * @param lon1 - 첫 번째 지점의 경도 + * @param lat2 - 두 번째 지점의 위도 + * @param lon2 - 두 번째 지점의 경도 + * @returns 두 지점 간의 거리 (km) + */ +export function calculateDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371; // 지구 반경 (km) + + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +/** + * 각도를 라디안으로 변환 + */ +function toRadians(degrees: number): number { + return degrees * (Math.PI / 180); +} + +/** + * 라디안을 각도로 변환 + */ +export function toDegrees(radians: number): number { + return radians * (180 / Math.PI); +} + +/** + * 좌표 배열에서 총 거리 계산 + * + * @param coordinates - { latitude, longitude }[] 형태의 좌표 배열 + * @returns 총 거리 (km) + */ +export function calculateTotalDistance( + coordinates: Array<{ latitude: number; longitude: number }> +): number { + let totalDistance = 0; + + for (let i = 1; i < coordinates.length; i++) { + const prev = coordinates[i - 1]; + const curr = coordinates[i]; + totalDistance += calculateDistance( + prev.latitude, + prev.longitude, + curr.latitude, + curr.longitude + ); + } + + return totalDistance; +} + +/** + * 좌표가 특정 반경 내에 있는지 확인 + * + * @param centerLat - 중심점 위도 + * @param centerLon - 중심점 경도 + * @param pointLat - 확인할 지점의 위도 + * @param pointLon - 확인할 지점의 경도 + * @param radiusKm - 반경 (km) + * @returns 반경 내에 있으면 true + */ +export function isWithinRadius( + centerLat: number, + centerLon: number, + pointLat: number, + pointLon: number, + radiusKm: number +): boolean { + const distance = calculateDistance(centerLat, centerLon, pointLat, pointLon); + return distance <= radiusKm; +} + +/** + * 두 좌표 사이의 방위각(bearing) 계산 + * + * @param lat1 - 시작점 위도 + * @param lon1 - 시작점 경도 + * @param lat2 - 도착점 위도 + * @param lon2 - 도착점 경도 + * @returns 방위각 (0-360도) + */ +export function calculateBearing( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const dLon = toRadians(lon2 - lon1); + const lat1Rad = toRadians(lat1); + const lat2Rad = toRadians(lat2); + + const x = Math.sin(dLon) * Math.cos(lat2Rad); + const y = + Math.cos(lat1Rad) * Math.sin(lat2Rad) - + Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon); + + let bearing = toDegrees(Math.atan2(x, y)); + bearing = (bearing + 360) % 360; // 0-360 범위로 정규화 + + return bearing; +} + +/** + * 좌표 배열의 경계 상자(bounding box) 계산 + * + * @param coordinates - 좌표 배열 + * @returns { minLat, maxLat, minLon, maxLon } + */ +export function getBoundingBox( + coordinates: Array<{ latitude: number; longitude: number }> +): { minLat: number; maxLat: number; minLon: number; maxLon: number } | null { + if (coordinates.length === 0) return null; + + let minLat = coordinates[0].latitude; + let maxLat = coordinates[0].latitude; + let minLon = coordinates[0].longitude; + let maxLon = coordinates[0].longitude; + + for (const coord of coordinates) { + minLat = Math.min(minLat, coord.latitude); + maxLat = Math.max(maxLat, coord.latitude); + minLon = Math.min(minLon, coord.longitude); + maxLon = Math.max(maxLon, coord.longitude); + } + + return { minLat, maxLat, minLon, maxLon }; +} + +/** + * 좌표 배열의 중심점 계산 + * + * @param coordinates - 좌표 배열 + * @returns { latitude, longitude } 중심점 + */ +export function getCenterPoint( + coordinates: Array<{ latitude: number; longitude: number }> +): { latitude: number; longitude: number } | null { + if (coordinates.length === 0) return null; + + let sumLat = 0; + let sumLon = 0; + + for (const coord of coordinates) { + sumLat += coord.latitude; + sumLon += coord.longitude; + } + + return { + latitude: sumLat / coordinates.length, + longitude: sumLon / coordinates.length, + }; +} diff --git a/frontend/app/(admin)/admin/vehicle-reports/page.tsx b/frontend/app/(admin)/admin/vehicle-reports/page.tsx new file mode 100644 index 00000000..ce84f584 --- /dev/null +++ b/frontend/app/(admin)/admin/vehicle-reports/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const VehicleReport = dynamic( + () => import("@/components/vehicle/VehicleReport"), + { + ssr: false, + loading: () => ( +
+
로딩 중...
+
+ ), + } +); + +export default function VehicleReportsPage() { + return ( +
+
+

운행 리포트

+

+ 차량 운행 통계 및 분석 리포트를 확인합니다. +

+
+ +
+ ); +} + diff --git a/frontend/app/(admin)/admin/vehicle-trips/page.tsx b/frontend/app/(admin)/admin/vehicle-trips/page.tsx new file mode 100644 index 00000000..fea63166 --- /dev/null +++ b/frontend/app/(admin)/admin/vehicle-trips/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const VehicleTripHistory = dynamic( + () => import("@/components/vehicle/VehicleTripHistory"), + { + ssr: false, + loading: () => ( +
+
로딩 중...
+
+ ), + } +); + +export default function VehicleTripsPage() { + return ( +
+
+

운행 이력 관리

+

+ 차량 운행 이력을 조회하고 관리합니다. +

+
+ +
+ ); +} diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 1ed37a49..ba88befd 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -503,7 +503,7 @@ export const ButtonConfigPanel: React.FC = ({ 엑셀 업로드 바코드 스캔 코드 병합 - 공차등록 + {/* 공차등록 */} 운행알림 및 종료 @@ -1664,190 +1664,12 @@ export const ButtonConfigPanel: React.FC = ({
)} - {/* 위치정보 가져오기 설정 */} - {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && ( + {/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */} + {/* {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
-

🚛 공차등록 설정

- - {/* 테이블 선택 */} -
- - -
- -
-
- - onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)} - className="h-8 text-xs" - /> -
-
- -
-
- - onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)} - className="h-8 text-xs" - /> -
-
- -
-
- -

GPS 사용 (배터리 소모 증가)

-
- onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)} - /> -
- - {/* 자동 저장 옵션 */} -
-
-
- -

위치 정보를 바로 DB에 저장

-
- onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)} - /> -
- - {config.action?.geolocationAutoSave && ( -
-
-
- - onUpdateProperty("componentConfig.action.geolocationKeyField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - -
-
- - {/* 추가 필드 변경 (status 등) */} -
-
- - onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)} - className="h-8 text-xs" - /> -
-
- - onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)} - className="h-8 text-xs" - /> -
-
-

- 위치 정보와 함께 status 같은 필드도 변경할 수 있습니다. -

-
- )} -
- -
-

- 참고: HTTPS 환경에서만 작동합니다. -

-
+ ... 공차등록 설정 UI 생략 ...
- )} + )} */} {/* 운행알림 및 종료 설정 */} {(component.componentConfig?.action?.type || "save") === "operation_control" && ( diff --git a/frontend/components/vehicle/VehicleReport.tsx b/frontend/components/vehicle/VehicleReport.tsx new file mode 100644 index 00000000..51773c98 --- /dev/null +++ b/frontend/components/vehicle/VehicleReport.tsx @@ -0,0 +1,660 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + getSummaryReport, + getDailyReport, + getMonthlyReport, + getDriverReport, + getRouteReport, + formatDistance, + formatDuration, + SummaryReport, + DailyStat, + MonthlyStat, + DriverStat, + RouteStat, +} from "@/lib/api/vehicleTrip"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + RefreshCw, + Car, + Route, + Clock, + Users, + TrendingUp, + MapPin, +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; + +export default function VehicleReport() { + // 요약 통계 + const [summary, setSummary] = useState(null); + const [summaryPeriod, setSummaryPeriod] = useState("month"); + const [summaryLoading, setSummaryLoading] = useState(false); + + // 일별 통계 + const [dailyData, setDailyData] = useState([]); + const [dailyStartDate, setDailyStartDate] = useState( + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] + ); + const [dailyEndDate, setDailyEndDate] = useState( + new Date().toISOString().split("T")[0] + ); + const [dailyLoading, setDailyLoading] = useState(false); + + // 월별 통계 + const [monthlyData, setMonthlyData] = useState([]); + const [monthlyYear, setMonthlyYear] = useState(new Date().getFullYear()); + const [monthlyLoading, setMonthlyLoading] = useState(false); + + // 운전자별 통계 + const [driverData, setDriverData] = useState([]); + const [driverLoading, setDriverLoading] = useState(false); + + // 구간별 통계 + const [routeData, setRouteData] = useState([]); + const [routeLoading, setRouteLoading] = useState(false); + + // 요약 로드 + const loadSummary = useCallback(async () => { + setSummaryLoading(true); + try { + const response = await getSummaryReport(summaryPeriod); + if (response.success) { + setSummary(response.data); + } + } catch (error) { + console.error("요약 통계 조회 실패:", error); + } finally { + setSummaryLoading(false); + } + }, [summaryPeriod]); + + // 일별 로드 + const loadDaily = useCallback(async () => { + setDailyLoading(true); + try { + const response = await getDailyReport({ + startDate: dailyStartDate, + endDate: dailyEndDate, + }); + if (response.success) { + setDailyData(response.data?.data || []); + } + } catch (error) { + console.error("일별 통계 조회 실패:", error); + } finally { + setDailyLoading(false); + } + }, [dailyStartDate, dailyEndDate]); + + // 월별 로드 + const loadMonthly = useCallback(async () => { + setMonthlyLoading(true); + try { + const response = await getMonthlyReport({ year: monthlyYear }); + if (response.success) { + setMonthlyData(response.data?.data || []); + } + } catch (error) { + console.error("월별 통계 조회 실패:", error); + } finally { + setMonthlyLoading(false); + } + }, [monthlyYear]); + + // 운전자별 로드 + const loadDrivers = useCallback(async () => { + setDriverLoading(true); + try { + const response = await getDriverReport({ limit: 20 }); + if (response.success) { + setDriverData(response.data || []); + } + } catch (error) { + console.error("운전자별 통계 조회 실패:", error); + } finally { + setDriverLoading(false); + } + }, []); + + // 구간별 로드 + const loadRoutes = useCallback(async () => { + setRouteLoading(true); + try { + const response = await getRouteReport({ limit: 20 }); + if (response.success) { + setRouteData(response.data || []); + } + } catch (error) { + console.error("구간별 통계 조회 실패:", error); + } finally { + setRouteLoading(false); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadSummary(); + }, [loadSummary]); + + // 기간 레이블 + const getPeriodLabel = (period: string) => { + switch (period) { + case "today": + return "오늘"; + case "week": + return "최근 7일"; + case "month": + return "최근 30일"; + case "year": + return "올해"; + default: + return period; + } + }; + + return ( +
+ {/* 요약 통계 카드 */} +
+
+

요약 통계

+
+ + +
+
+ + {summary && ( +
+ + +
+ + 총 운행 +
+
+ {summary.totalTrips.toLocaleString()} +
+
+ {getPeriodLabel(summaryPeriod)} +
+
+
+ + + +
+ + 완료율 +
+
+ {summary.completionRate}% +
+
+ {summary.completedTrips} / {summary.totalTrips} +
+
+
+ + + +
+ + 총 거리 +
+
+ {formatDistance(summary.totalDistance)} +
+
+ 평균 {formatDistance(summary.avgDistance)} +
+
+
+ + + +
+ + 총 시간 +
+
+ {formatDuration(summary.totalDuration)} +
+
+ 평균 {formatDuration(Math.round(summary.avgDuration))} +
+
+
+ + + +
+ + 운전자 +
+
+ {summary.activeDrivers} +
+
활동 중
+
+
+ + + +
+ + 진행 중 +
+
+ {summary.activeTrips} +
+
현재 운행
+
+
+
+ )} +
+ + {/* 상세 통계 탭 */} + + + + 일별 통계 + + + 월별 통계 + + + 운전자별 + + + 구간별 + + + + {/* 일별 통계 */} + + + +
+ 일별 운행 통계 +
+
+ + setDailyStartDate(e.target.value)} + className="h-8 w-[130px]" + /> +
+
+ + setDailyEndDate(e.target.value)} + className="h-8 w-[130px]" + /> +
+ +
+
+
+ +
+ + + + 날짜 + 운행 수 + 완료 + 취소 + 총 거리 + 평균 거리 + 총 시간 + + + + {dailyLoading ? ( + + + 로딩 중... + + + ) : dailyData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + dailyData.map((row) => ( + + + {format(new Date(row.date), "MM/dd (E)", { + locale: ko, + })} + + + {row.tripCount} + + + {row.completedCount} + + + {row.cancelledCount} + + + {formatDistance(row.totalDistance)} + + + {formatDistance(row.avgDistance)} + + + {formatDuration(row.totalDuration)} + + + )) + )} + +
+
+
+
+
+ + {/* 월별 통계 */} + + + +
+ 월별 운행 통계 +
+ + +
+
+
+ +
+ + + + + 운행 수 + 완료 + 취소 + 총 거리 + 평균 거리 + 운전자 수 + + + + {monthlyLoading ? ( + + + 로딩 중... + + + ) : monthlyData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + monthlyData.map((row) => ( + + {row.month}월 + + {row.tripCount} + + + {row.completedCount} + + + {row.cancelledCount} + + + {formatDistance(row.totalDistance)} + + + {formatDistance(row.avgDistance)} + + + {row.driverCount} + + + )) + )} + +
+
+
+
+
+ + {/* 운전자별 통계 */} + + + +
+ 운전자별 통계 + +
+
+ +
+ + + + 운전자 + 운행 수 + 완료 + 총 거리 + 평균 거리 + 총 시간 + + + + {driverLoading ? ( + + + 로딩 중... + + + ) : driverData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + driverData.map((row) => ( + + + {row.userName} + + + {row.tripCount} + + + {row.completedCount} + + + {formatDistance(row.totalDistance)} + + + {formatDistance(row.avgDistance)} + + + {formatDuration(row.totalDuration)} + + + )) + )} + +
+
+
+
+
+ + {/* 구간별 통계 */} + + + +
+ 구간별 통계 + +
+
+ +
+ + + + +
+ + 출발지 +
+
+ +
+ + 도착지 +
+
+ 운행 수 + 총 거리 + 평균 거리 + 평균 시간 +
+
+ + {routeLoading ? ( + + + 로딩 중... + + + ) : routeData.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + routeData.map((row, idx) => ( + + {row.departureName} + {row.destinationName} + + {row.tripCount} + + + {formatDistance(row.totalDistance)} + + + {formatDistance(row.avgDistance)} + + + {formatDuration(Math.round(row.avgDuration))} + + + )) + )} + +
+
+
+
+
+
+
+ ); +} + diff --git a/frontend/components/vehicle/VehicleTripHistory.tsx b/frontend/components/vehicle/VehicleTripHistory.tsx new file mode 100644 index 00000000..3c4bcb57 --- /dev/null +++ b/frontend/components/vehicle/VehicleTripHistory.tsx @@ -0,0 +1,531 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + getTripList, + getTripDetail, + formatDistance, + formatDuration, + getStatusLabel, + getStatusColor, + TripSummary, + TripDetail, + TripListFilters, +} from "@/lib/api/vehicleTrip"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Search, + RefreshCw, + MapPin, + Clock, + Route, + ChevronLeft, + ChevronRight, + Eye, +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; + +const PAGE_SIZE = 20; + +export default function VehicleTripHistory() { + // 상태 + const [trips, setTrips] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + + // 필터 + const [filters, setFilters] = useState({ + status: "", + startDate: "", + endDate: "", + departure: "", + arrival: "", + }); + + // 상세 모달 + const [selectedTrip, setSelectedTrip] = useState(null); + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + + // 데이터 로드 + const loadTrips = useCallback(async () => { + setLoading(true); + try { + const response = await getTripList({ + ...filters, + status: filters.status || undefined, + startDate: filters.startDate || undefined, + endDate: filters.endDate || undefined, + departure: filters.departure || undefined, + arrival: filters.arrival || undefined, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }); + + if (response.success) { + setTrips(response.data || []); + setTotal(response.total || 0); + } + } catch (error) { + console.error("운행 이력 조회 실패:", error); + } finally { + setLoading(false); + } + }, [filters, page]); + + useEffect(() => { + loadTrips(); + }, [loadTrips]); + + // 상세 조회 + const handleViewDetail = async (tripId: string) => { + setDetailLoading(true); + setDetailModalOpen(true); + try { + const response = await getTripDetail(tripId); + if (response.success && response.data) { + setSelectedTrip(response.data); + } + } catch (error) { + console.error("운행 상세 조회 실패:", error); + } finally { + setDetailLoading(false); + } + }; + + // 필터 변경 + const handleFilterChange = (key: keyof TripListFilters, value: string) => { + setFilters((prev) => ({ ...prev, [key]: value })); + setPage(1); + }; + + // 검색 + const handleSearch = () => { + setPage(1); + loadTrips(); + }; + + // 초기화 + const handleReset = () => { + setFilters({ + status: "", + startDate: "", + endDate: "", + departure: "", + arrival: "", + }); + setPage(1); + }; + + // 페이지네이션 + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ {/* 필터 영역 */} + + + 검색 조건 + + +
+ {/* 상태 */} +
+ + +
+ + {/* 시작일 */} +
+ + handleFilterChange("startDate", e.target.value)} + className="h-9" + /> +
+ + {/* 종료일 */} +
+ + handleFilterChange("endDate", e.target.value)} + className="h-9" + /> +
+ + {/* 출발지 */} +
+ + handleFilterChange("departure", e.target.value)} + className="h-9" + /> +
+ + {/* 도착지 */} +
+ + handleFilterChange("arrival", e.target.value)} + className="h-9" + /> +
+
+ +
+ + +
+
+
+ + {/* 목록 */} + + +
+ + 운행 이력 ({total.toLocaleString()}건) + + +
+
+ +
+ + + + 운행ID + 운전자 + 출발지 + 도착지 + 시작 시간 + 종료 시간 + 거리 + 시간 + 상태 + + + + + {loading ? ( + + + 로딩 중... + + + ) : trips.length === 0 ? ( + + + 운행 이력이 없습니다. + + + ) : ( + trips.map((trip) => ( + + + {trip.trip_id.substring(0, 15)}... + + {trip.user_name || trip.user_id} + {trip.departure_name || trip.departure || "-"} + {trip.destination_name || trip.arrival || "-"} + + {format(new Date(trip.start_time), "MM/dd HH:mm", { + locale: ko, + })} + + + {trip.end_time + ? format(new Date(trip.end_time), "MM/dd HH:mm", { + locale: ko, + }) + : "-"} + + + {trip.total_distance + ? formatDistance(Number(trip.total_distance)) + : "-"} + + + {trip.duration_minutes + ? formatDuration(trip.duration_minutes) + : "-"} + + + + {getStatusLabel(trip.status)} + + + + + + + )) + )} + +
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} +
+
+ + {/* 상세 모달 */} + + + + 운행 상세 정보 + + + {detailLoading ? ( +
+ 로딩 중... +
+ ) : selectedTrip ? ( +
+ {/* 요약 정보 */} +
+
+
+ + 출발지 +
+
+ {selectedTrip.summary.departure_name || + selectedTrip.summary.departure || + "-"} +
+
+
+
+ + 도착지 +
+
+ {selectedTrip.summary.destination_name || + selectedTrip.summary.arrival || + "-"} +
+
+
+
+ + 총 거리 +
+
+ {selectedTrip.summary.total_distance + ? formatDistance(Number(selectedTrip.summary.total_distance)) + : "-"} +
+
+
+
+ + 운행 시간 +
+
+ {selectedTrip.summary.duration_minutes + ? formatDuration(selectedTrip.summary.duration_minutes) + : "-"} +
+
+
+ + {/* 운행 정보 */} +
+

운행 정보

+
+
+ 운행 ID + + {selectedTrip.summary.trip_id} + +
+
+ 운전자 + + {selectedTrip.summary.user_name || + selectedTrip.summary.user_id} + +
+
+ 시작 시간 + + {format( + new Date(selectedTrip.summary.start_time), + "yyyy-MM-dd HH:mm:ss", + { locale: ko } + )} + +
+
+ 종료 시간 + + {selectedTrip.summary.end_time + ? format( + new Date(selectedTrip.summary.end_time), + "yyyy-MM-dd HH:mm:ss", + { locale: ko } + ) + : "-"} + +
+
+ 상태 + + {getStatusLabel(selectedTrip.summary.status)} + +
+
+ 위치 기록 수 + {selectedTrip.summary.location_count}개 +
+
+
+ + {/* 경로 데이터 */} + {selectedTrip.route && selectedTrip.route.length > 0 && ( +
+

+ 경로 데이터 ({selectedTrip.route.length}개 지점) +

+
+ + + + # + 위도 + 경도 + 정확도 + 이전 거리 + 기록 시간 + + + + {selectedTrip.route.map((loc, idx) => ( + + {idx + 1} + + {loc.latitude.toFixed(6)} + + + {loc.longitude.toFixed(6)} + + + {loc.accuracy ? `${loc.accuracy.toFixed(0)}m` : "-"} + + + {loc.distance_from_prev + ? formatDistance(Number(loc.distance_from_prev)) + : "-"} + + + {format(new Date(loc.recorded_at), "HH:mm:ss", { + locale: ko, + })} + + + ))} + +
+
+
+ )} +
+ ) : ( +
+ 데이터를 불러올 수 없습니다. +
+ )} +
+
+
+ ); +} + diff --git a/frontend/lib/api/vehicleTrip.ts b/frontend/lib/api/vehicleTrip.ts new file mode 100644 index 00000000..2e452dd6 --- /dev/null +++ b/frontend/lib/api/vehicleTrip.ts @@ -0,0 +1,368 @@ +/** + * 차량 운행 이력 API 클라이언트 + */ +import { apiClient } from "./client"; + +// 타입 정의 +export interface TripSummary { + id: number; + trip_id: string; + user_id: string; + user_name?: string; + vehicle_id?: number; + vehicle_number?: string; + departure?: string; + arrival?: string; + departure_name?: string; + destination_name?: string; + start_time: string; + end_time?: string; + total_distance: number; + duration_minutes?: number; + status: "active" | "completed" | "cancelled"; + location_count: number; + company_code: string; + created_at: string; +} + +export interface TripLocation { + id: number; + latitude: number; + longitude: number; + accuracy?: number; + speed?: number; + distance_from_prev?: number; + trip_status: "start" | "tracking" | "end"; + recorded_at: string; +} + +export interface TripDetail { + summary: TripSummary; + route: TripLocation[]; +} + +export interface TripListFilters { + userId?: string; + vehicleId?: number; + status?: string; + startDate?: string; + endDate?: string; + departure?: string; + arrival?: string; + limit?: number; + offset?: number; +} + +export interface StartTripParams { + vehicleId?: number; + departure?: string; + arrival?: string; + departureName?: string; + destinationName?: string; + latitude: number; + longitude: number; +} + +export interface EndTripParams { + tripId: string; + latitude: number; + longitude: number; +} + +export interface AddLocationParams { + tripId: string; + latitude: number; + longitude: number; + accuracy?: number; + speed?: number; +} + +// API 함수들 + +/** + * 운행 시작 + */ +export async function startTrip(params: StartTripParams) { + const response = await apiClient.post("/vehicle/trip/start", params); + return response.data; +} + +/** + * 운행 종료 + */ +export async function endTrip(params: EndTripParams) { + const response = await apiClient.post("/vehicle/trip/end", params); + return response.data; +} + +/** + * 위치 기록 추가 (연속 추적) + */ +export async function addTripLocation(params: AddLocationParams) { + const response = await apiClient.post("/vehicle/trip/location", params); + return response.data; +} + +/** + * 활성 운행 조회 + */ +export async function getActiveTrip() { + const response = await apiClient.get("/vehicle/trip/active"); + return response.data; +} + +/** + * 운행 취소 + */ +export async function cancelTrip(tripId: string) { + const response = await apiClient.post("/vehicle/trip/cancel", { tripId }); + return response.data; +} + +/** + * 운행 이력 목록 조회 + */ +export async function getTripList(filters?: TripListFilters) { + const params = new URLSearchParams(); + + if (filters) { + if (filters.userId) params.append("userId", filters.userId); + if (filters.vehicleId) params.append("vehicleId", String(filters.vehicleId)); + if (filters.status) params.append("status", filters.status); + if (filters.startDate) params.append("startDate", filters.startDate); + if (filters.endDate) params.append("endDate", filters.endDate); + if (filters.departure) params.append("departure", filters.departure); + if (filters.arrival) params.append("arrival", filters.arrival); + if (filters.limit) params.append("limit", String(filters.limit)); + if (filters.offset) params.append("offset", String(filters.offset)); + } + + const queryString = params.toString(); + const url = queryString ? `/vehicle/trips?${queryString}` : "/vehicle/trips"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 운행 상세 조회 (경로 포함) + */ +export async function getTripDetail(tripId: string): Promise<{ success: boolean; data?: TripDetail; message?: string }> { + const response = await apiClient.get(`/vehicle/trips/${tripId}`); + return response.data; +} + +/** + * 거리 포맷팅 (km) + */ +export function formatDistance(distanceKm: number): string { + if (distanceKm < 1) { + return `${Math.round(distanceKm * 1000)}m`; + } + return `${distanceKm.toFixed(2)}km`; +} + +/** + * 운행 시간 포맷팅 + */ +export function formatDuration(minutes: number): string { + if (minutes < 60) { + return `${minutes}분`; + } + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`; +} + +/** + * 상태 한글 변환 + */ +export function getStatusLabel(status: string): string { + switch (status) { + case "active": + return "운행 중"; + case "completed": + return "완료"; + case "cancelled": + return "취소됨"; + default: + return status; + } +} + +/** + * 상태별 색상 + */ +export function getStatusColor(status: string): string { + switch (status) { + case "active": + return "bg-green-100 text-green-800"; + case "completed": + return "bg-blue-100 text-blue-800"; + case "cancelled": + return "bg-gray-100 text-gray-800"; + default: + return "bg-gray-100 text-gray-800"; + } +} + +// ============== 리포트 API ============== + +export interface DailyStat { + date: string; + tripCount: number; + completedCount: number; + cancelledCount: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; + avgDuration: number; +} + +export interface WeeklyStat { + weekNumber: number; + weekStart: string; + weekEnd: string; + tripCount: number; + completedCount: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; +} + +export interface MonthlyStat { + month: number; + tripCount: number; + completedCount: number; + cancelledCount: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; + avgDuration: number; + driverCount: number; +} + +export interface SummaryReport { + period: string; + totalTrips: number; + completedTrips: number; + activeTrips: number; + cancelledTrips: number; + completionRate: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; + avgDuration: number; + activeDrivers: number; +} + +export interface DriverStat { + userId: string; + userName: string; + tripCount: number; + completedCount: number; + totalDistance: number; + totalDuration: number; + avgDistance: number; +} + +export interface RouteStat { + departure: string; + arrival: string; + departureName: string; + destinationName: string; + tripCount: number; + completedCount: number; + totalDistance: number; + avgDistance: number; + avgDuration: number; +} + +/** + * 요약 통계 조회 (대시보드용) + */ +export async function getSummaryReport(period?: string) { + const url = period ? `/vehicle/reports/summary?period=${period}` : "/vehicle/reports/summary"; + const response = await apiClient.get(url); + return response.data; +} + +/** + * 일별 통계 조회 + */ +export async function getDailyReport(filters?: { startDate?: string; endDate?: string; userId?: string }) { + const params = new URLSearchParams(); + if (filters?.startDate) params.append("startDate", filters.startDate); + if (filters?.endDate) params.append("endDate", filters.endDate); + if (filters?.userId) params.append("userId", filters.userId); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/daily?${queryString}` : "/vehicle/reports/daily"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 주별 통계 조회 + */ +export async function getWeeklyReport(filters?: { year?: number; month?: number; userId?: string }) { + const params = new URLSearchParams(); + if (filters?.year) params.append("year", String(filters.year)); + if (filters?.month) params.append("month", String(filters.month)); + if (filters?.userId) params.append("userId", filters.userId); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/weekly?${queryString}` : "/vehicle/reports/weekly"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 월별 통계 조회 + */ +export async function getMonthlyReport(filters?: { year?: number; userId?: string }) { + const params = new URLSearchParams(); + if (filters?.year) params.append("year", String(filters.year)); + if (filters?.userId) params.append("userId", filters.userId); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/monthly?${queryString}` : "/vehicle/reports/monthly"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 운전자별 통계 조회 + */ +export async function getDriverReport(filters?: { startDate?: string; endDate?: string; limit?: number }) { + const params = new URLSearchParams(); + if (filters?.startDate) params.append("startDate", filters.startDate); + if (filters?.endDate) params.append("endDate", filters.endDate); + if (filters?.limit) params.append("limit", String(filters.limit)); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/by-driver?${queryString}` : "/vehicle/reports/by-driver"; + + const response = await apiClient.get(url); + return response.data; +} + +/** + * 구간별 통계 조회 + */ +export async function getRouteReport(filters?: { startDate?: string; endDate?: string; limit?: number }) { + const params = new URLSearchParams(); + if (filters?.startDate) params.append("startDate", filters.startDate); + if (filters?.endDate) params.append("endDate", filters.endDate); + if (filters?.limit) params.append("limit", String(filters.limit)); + + const queryString = params.toString(); + const url = queryString ? `/vehicle/reports/by-route?${queryString}` : "/vehicle/reports/by-route"; + + const response = await apiClient.get(url); + return response.data; +} + diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 2c7270a6..5be55b65 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -24,7 +24,7 @@ export type ButtonActionType = | "excel_upload" // 엑셀 업로드 | "barcode_scan" // 바코드 스캔 | "code_merge" // 코드 병합 - | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) + // | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합 | "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) | "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간) @@ -163,6 +163,10 @@ export interface ButtonActionConfig { updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택) updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택) + // 🆕 공차등록 연속 위치 추적 설정 (empty_vehicle 액션에서 사용) + emptyVehicleTracking?: boolean; // 공차 상태에서 연속 위치 추적 여부 (기본: true) + emptyVehicleTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초) + // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) editMode?: "modal" | "navigate" | "inline"; // 편집 모드 editModalTitle?: string; // 편집 모달 제목 @@ -350,8 +354,8 @@ export class ButtonActionExecutor { case "transferData": return await this.handleTransferData(config, context); - case "empty_vehicle": - return await this.handleEmptyVehicle(config, context); + // case "empty_vehicle": + // return await this.handleEmptyVehicle(config, context); case "operation_control": return await this.handleOperationControl(config, context); @@ -3501,6 +3505,7 @@ export class ButtonActionExecutor { /** * 위치 이력 테이블에 저장 (내부 헬퍼) + * + vehicles 테이블의 latitude/longitude도 함께 업데이트 */ private static async saveLocationToHistory( tripId: string | null, @@ -3517,13 +3522,15 @@ export class ButtonActionExecutor { try { const { apiClient } = await import("@/lib/api/client"); + const { latitude, longitude, accuracy, altitude, speed, heading } = position.coords; + const locationData = { - latitude: position.coords.latitude, - longitude: position.coords.longitude, - accuracy: position.coords.accuracy, - altitude: position.coords.altitude, - speed: position.coords.speed, - heading: position.coords.heading, + latitude, + longitude, + accuracy, + altitude, + speed, + heading, tripId, tripStatus, departure, @@ -3536,6 +3543,7 @@ export class ButtonActionExecutor { console.log("📍 [saveLocationToHistory] 위치 저장:", locationData); + // 1. vehicle_location_history에 저장 const response = await apiClient.post(`/dynamic-form/location-history`, locationData); if (response.data?.success) { @@ -3544,6 +3552,41 @@ export class ButtonActionExecutor { console.warn("⚠️ 위치 이력 저장 실패:", response.data); } + // 2. vehicles 테이블의 latitude/longitude도 업데이트 (실시간 위치 반영) + if (this.trackingContext && this.trackingConfig) { + const keyField = this.trackingConfig.trackingStatusKeyField || "user_id"; + const keySourceField = this.trackingConfig.trackingStatusKeySourceField || "__userId__"; + const keyValue = resolveSpecialKeyword(keySourceField, this.trackingContext); + const vehiclesTableName = this.trackingConfig.trackingStatusTableName || "vehicles"; + + if (keyValue) { + try { + // latitude 업데이트 + await apiClient.put(`/dynamic-form/update-field`, { + tableName: vehiclesTableName, + keyField, + keyValue, + updateField: "latitude", + updateValue: latitude, + }); + + // longitude 업데이트 + await apiClient.put(`/dynamic-form/update-field`, { + tableName: vehiclesTableName, + keyField, + keyValue, + updateField: "longitude", + updateValue: longitude, + }); + + console.log(`✅ vehicles 테이블 위치 업데이트: (${latitude.toFixed(6)}, ${longitude.toFixed(6)})`); + } catch (vehicleUpdateError) { + // 컬럼이 없으면 조용히 무시 + console.warn("⚠️ vehicles 테이블 위치 업데이트 실패 (무시):", vehicleUpdateError); + } + } + } + resolve(); } catch (error) { console.error("❌ 위치 이력 저장 오류:", error); @@ -3673,13 +3716,18 @@ export class ButtonActionExecutor { } } + // 공차 추적용 watchId 저장 + private static emptyVehicleWatchId: number | null = null; + private static emptyVehicleTripId: string | null = null; + /** * 공차등록 액션 처리 * - 위치 수집 + 상태 변경 (예: status → inactive) + * - 연속 위치 추적 시작 (vehicle_location_history에 저장) */ private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { - console.log("📍 위치정보 가져오기 액션 실행:", { config, context }); + console.log("📍 공차등록 액션 실행:", { config, context }); // 브라우저 Geolocation API 지원 확인 if (!navigator.geolocation) { @@ -3708,7 +3756,7 @@ export class ButtonActionExecutor { toast.dismiss(loadingToastId); - const { latitude, longitude, accuracy } = position.coords; + const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords; const timestamp = new Date(position.timestamp); console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy }); @@ -3777,8 +3825,15 @@ export class ButtonActionExecutor { } } console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`); + + // 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록) + if (config.emptyVehicleTracking !== false) { + await this.startEmptyVehicleTracking(config, context, { + latitude, longitude, accuracy, speed, heading, altitude + }); + } - toast.success(config.successMessage || "위치 정보가 저장되었습니다."); + toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다."); } catch (saveError) { console.error("❌ 위치정보 자동 저장 실패:", saveError); toast.error("위치 정보 저장에 실패했습니다."); @@ -3795,7 +3850,7 @@ export class ButtonActionExecutor { return true; } catch (error: any) { - console.error("❌ 위치정보 가져오기 실패:", error); + console.error("❌ 공차등록 실패:", error); toast.dismiss(); // GeolocationPositionError 처리 @@ -3821,6 +3876,122 @@ export class ButtonActionExecutor { } } + /** + * 공차 상태에서 연속 위치 추적 시작 + */ + private static async startEmptyVehicleTracking( + config: ButtonActionConfig, + context: ButtonActionContext, + initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null } + ): Promise { + try { + // 기존 추적이 있으면 중지 + if (this.emptyVehicleWatchId !== null) { + navigator.geolocation.clearWatch(this.emptyVehicleWatchId); + this.emptyVehicleWatchId = null; + } + + const { apiClient } = await import("@/lib/api/client"); + + // Trip ID 생성 (공차용) + const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; + this.emptyVehicleTripId = tripId; + + const userId = context.userId || ""; + const companyCode = context.companyCode || ""; + const departure = context.formData?.departure || ""; + const arrival = context.formData?.arrival || ""; + const departureName = context.formData?.departure_name || ""; + const destinationName = context.formData?.destination_name || ""; + + // 시작 위치 기록 + try { + await apiClient.post("/dynamic-form/location-history", { + tripId, + userId, + latitude: initialPosition.latitude, + longitude: initialPosition.longitude, + accuracy: initialPosition.accuracy, + speed: initialPosition.speed, + heading: initialPosition.heading, + altitude: initialPosition.altitude, + tripStatus: "empty_start", // 공차 시작 + departure, + arrival, + departureName, + destinationName, + companyCode, + }); + console.log("📍 공차 시작 위치 기록 완료:", tripId); + } catch (err) { + console.warn("⚠️ 공차 시작 위치 기록 실패 (테이블 없을 수 있음):", err); + } + + // 추적 간격 (기본 10초) + const trackingInterval = config.emptyVehicleTrackingInterval || 10000; + + // watchPosition으로 연속 추적 + this.emptyVehicleWatchId = navigator.geolocation.watchPosition( + async (position) => { + const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords; + + try { + await apiClient.post("/dynamic-form/location-history", { + tripId: this.emptyVehicleTripId, + userId, + latitude, + longitude, + accuracy, + speed, + heading, + altitude, + tripStatus: "empty_tracking", // 공차 추적 중 + departure, + arrival, + departureName, + destinationName, + companyCode, + }); + console.log("📍 공차 위치 기록:", { latitude: latitude.toFixed(6), longitude: longitude.toFixed(6) }); + } catch (err) { + console.warn("⚠️ 공차 위치 기록 실패:", err); + } + }, + (error) => { + console.error("❌ 공차 위치 추적 오류:", error.message); + }, + { + enableHighAccuracy: true, + timeout: trackingInterval, + maximumAge: 0, + } + ); + + console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId }); + } catch (error) { + console.error("❌ 공차 위치 추적 시작 실패:", error); + } + } + + /** + * 공차 위치 추적 중지 (운행 전환 시 호출) + */ + public static stopEmptyVehicleTracking(): void { + if (this.emptyVehicleWatchId !== null) { + navigator.geolocation.clearWatch(this.emptyVehicleWatchId); + console.log("🛑 공차 위치 추적 중지:", { tripId: this.emptyVehicleTripId, watchId: this.emptyVehicleWatchId }); + this.emptyVehicleWatchId = null; + this.emptyVehicleTripId = null; + } + } + + /** + * 현재 공차 추적 Trip ID 반환 + */ + public static getEmptyVehicleTripId(): string | null { + return this.emptyVehicleTripId; + } + /** * 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지) */ @@ -3885,7 +4056,13 @@ export class ButtonActionExecutor { */ private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { - console.log("🔄 필드 값 변경 액션 실행:", { config, context }); + console.log("🔄 운행알림/종료 액션 실행:", { config, context }); + + // 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료) + if (this.emptyVehicleWatchId !== null) { + this.stopEmptyVehicleTracking(); + console.log("🛑 공차 추적 종료 후 운행 시작"); + } // 🆕 연속 위치 추적 모드 처리 if (config.updateWithTracking) { From 2c447fd32576b870e931608f94133f3c9cfc4d04 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 2 Dec 2025 13:20:49 +0900 Subject: [PATCH 05/18] =?UTF-8?q?restapi=EB=8F=84=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/flowController.ts | 34 +- .../controllers/screenManagementController.ts | 35 +- .../src/services/flowDefinitionService.ts | 18 +- .../src/services/screenManagementService.ts | 64 ++- backend-node/src/types/flow.ts | 12 +- .../app/(main)/admin/flow-management/page.tsx | 409 ++++++++++++------ frontend/components/screen/ScreenDesigner.tsx | 47 +- frontend/components/screen/ScreenList.tsx | 300 ++++++++++--- .../table-list/TableListComponent.tsx | 83 ++-- frontend/types/flow.ts | 7 + 10 files changed, 749 insertions(+), 260 deletions(-) diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 85ad2259..e03bfe25 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -32,8 +32,17 @@ export class FlowController { */ createFlowDefinition = async (req: Request, res: Response): Promise => { try { - const { name, description, tableName, dbSourceType, dbConnectionId } = - req.body; + const { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + // REST API 관련 필드 + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + } = req.body; const userId = (req as any).user?.userId || "system"; const userCompanyCode = (req as any).user?.companyCode; @@ -43,6 +52,9 @@ export class FlowController { tableName, dbSourceType, dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, userCompanyCode, }); @@ -54,8 +66,11 @@ export class FlowController { return; } - // 테이블 이름이 제공된 경우에만 존재 확인 - if (tableName) { + // REST API인 경우 테이블 존재 확인 스킵 + const isRestApi = dbSourceType === "restapi"; + + // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외) + if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) { const tableExists = await this.flowDefinitionService.checkTableExists(tableName); if (!tableExists) { @@ -68,7 +83,16 @@ export class FlowController { } const flowDef = await this.flowDefinitionService.create( - { name, description, tableName, dbSourceType, dbConnectionId }, + { + name, + description, + tableName, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }, userId, userCompanyCode ); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 0ff80988..c7ecf75e 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -148,11 +148,42 @@ export const updateScreenInfo = async ( try { const { id } = req.params; const { companyCode } = req.user as any; - const { screenName, tableName, description, isActive } = req.body; + const { + screenName, + tableName, + description, + isActive, + // REST API 관련 필드 추가 + dataSourceType, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + } = req.body; + + console.log("화면 정보 수정 요청:", { + screenId: id, + dataSourceType, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }); await screenManagementService.updateScreenInfo( parseInt(id), - { screenName, tableName, description, isActive }, + { + screenName, + tableName, + description, + isActive, + dataSourceType, + dbSourceType, + dbConnectionId, + restApiConnectionId, + restApiEndpoint, + restApiJsonPath, + }, companyCode ); res.json({ success: true, message: "화면 정보가 수정되었습니다." }); diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 759178c1..4416faa0 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -27,13 +27,20 @@ export class FlowDefinitionService { tableName: request.tableName, dbSourceType: request.dbSourceType, dbConnectionId: request.dbConnectionId, + restApiConnectionId: request.restApiConnectionId, + restApiEndpoint: request.restApiEndpoint, + restApiJsonPath: request.restApiJsonPath, companyCode, userId, }); const query = ` - INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO flow_definition ( + name, description, table_name, db_source_type, db_connection_id, + rest_api_connection_id, rest_api_endpoint, rest_api_json_path, + company_code, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * `; @@ -43,6 +50,9 @@ export class FlowDefinitionService { request.tableName || null, request.dbSourceType || "internal", request.dbConnectionId || null, + request.restApiConnectionId || null, + request.restApiEndpoint || null, + request.restApiJsonPath || "data", companyCode, userId, ]; @@ -206,6 +216,10 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, + // REST API 관련 필드 + restApiConnectionId: row.rest_api_connection_id, + restApiEndpoint: row.rest_api_endpoint, + restApiJsonPath: row.rest_api_json_path, companyCode: row.company_code || "*", isActive: row.is_active, createdBy: row.created_by, diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 71550fd6..007a39e7 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -326,7 +326,19 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, + updateData: { + screenName: string; + tableName?: string; + description?: string; + isActive: string; + // REST API 관련 필드 추가 + dataSourceType?: string; + dbSourceType?: string; + dbConnectionId?: number; + restApiConnectionId?: number; + restApiEndpoint?: string; + restApiJsonPath?: string; + }, userCompanyCode: string ): Promise { // 권한 확인 @@ -348,24 +360,43 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 (tableName 포함) + // 화면 정보 업데이트 (REST API 필드 포함) await query( `UPDATE screen_definitions SET screen_name = $1, table_name = $2, description = $3, is_active = $4, - updated_date = $5 - WHERE screen_id = $6`, + updated_date = $5, + data_source_type = $6, + db_source_type = $7, + db_connection_id = $8, + rest_api_connection_id = $9, + rest_api_endpoint = $10, + rest_api_json_path = $11 + WHERE screen_id = $12`, [ updateData.screenName, updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), + updateData.dataSourceType || "database", + updateData.dbSourceType || "internal", + updateData.dbConnectionId || null, + updateData.restApiConnectionId || null, + updateData.restApiEndpoint || null, + updateData.restApiJsonPath || null, screenId, ] ); + + console.log(`화면 정보 업데이트 완료: screenId=${screenId}`, { + dataSourceType: updateData.dataSourceType, + restApiConnectionId: updateData.restApiConnectionId, + restApiEndpoint: updateData.restApiEndpoint, + restApiJsonPath: updateData.restApiJsonPath, + }); } /** @@ -2016,37 +2047,40 @@ export class ScreenManagementService { // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기) await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]); - // 해당 회사의 기존 화면 코드들 조회 + // 해당 회사의 기존 화면 코드들 조회 (모든 화면 - 삭제된 코드도 재사용 방지) + // LIMIT 제거하고 숫자 추출하여 최대값 찾기 const existingScreens = await client.query<{ screen_code: string }>( `SELECT screen_code FROM screen_definitions - WHERE company_code = $1 AND screen_code LIKE $2 - ORDER BY screen_code DESC - LIMIT 10`, - [companyCode, `${companyCode}%`] + WHERE screen_code LIKE $1 + ORDER BY screen_code DESC`, + [`${companyCode}_%`] ); // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 let maxNumber = 0; const pattern = new RegExp( - `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` + `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_(\\d+)$` ); + console.log(`🔍 화면 코드 생성 - 조회된 화면 수: ${existingScreens.rows.length}`); + console.log(`🔍 패턴: ${pattern}`); + for (const screen of existingScreens.rows) { const match = screen.screen_code.match(pattern); if (match) { const number = parseInt(match[1], 10); + console.log(`🔍 매칭: ${screen.screen_code} → 숫자: ${number}`); if (number > maxNumber) { maxNumber = number; } } } - // 다음 순번으로 화면 코드 생성 (3자리 패딩) + // 다음 순번으로 화면 코드 생성 const nextNumber = maxNumber + 1; - const paddedNumber = nextNumber.toString().padStart(3, "0"); - - const newCode = `${companyCode}_${paddedNumber}`; - console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber})`); + // 숫자가 3자리 이상이면 패딩 없이, 아니면 3자리 패딩 + const newCode = `${companyCode}_${nextNumber}`; + console.log(`🔢 화면 코드 생성: ${companyCode} → ${newCode} (maxNumber: ${maxNumber}, nextNumber: ${nextNumber})`); return newCode; // Advisory lock은 트랜잭션 종료 시 자동으로 해제됨 diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index c127eccc..c877a2b3 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -8,8 +8,12 @@ export interface FlowDefinition { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) + // REST API 관련 필드 + restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; @@ -22,8 +26,12 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입 + dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID + // REST API 관련 필드 + restApiConnectionId?: number; // REST API 연결 ID + restApiEndpoint?: string; // REST API 엔드포인트 + restApiJsonPath?: string; // JSON 응답에서 데이터 경로 companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index bb2bf04a..5a335daf 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -34,6 +34,7 @@ import { formatErrorMessage } from "@/lib/utils/errorUtils"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; export default function FlowManagementPage() { const router = useRouter(); @@ -52,13 +53,19 @@ export default function FlowManagementPage() { ); const [loadingTables, setLoadingTables] = useState(false); const [openTableCombobox, setOpenTableCombobox] = useState(false); - const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID + // 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API) + const [selectedDbSource, setSelectedDbSource] = useState("internal"); const [externalConnections, setExternalConnections] = useState< Array<{ id: number; connection_name: string; db_type: string }> >([]); const [externalTableList, setExternalTableList] = useState([]); const [loadingExternalTables, setLoadingExternalTables] = useState(false); + // REST API 연결 관련 상태 + const [restApiConnections, setRestApiConnections] = useState([]); + const [restApiEndpoint, setRestApiEndpoint] = useState(""); + const [restApiJsonPath, setRestApiJsonPath] = useState("data"); + // 생성 폼 상태 const [formData, setFormData] = useState({ name: "", @@ -135,75 +142,132 @@ export default function FlowManagementPage() { loadConnections(); }, []); + // REST API 연결 목록 로드 + useEffect(() => { + const loadRestApiConnections = async () => { + try { + const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" }); + setRestApiConnections(connections); + } catch (error) { + console.error("Failed to load REST API connections:", error); + setRestApiConnections([]); + } + }; + loadRestApiConnections(); + }, []); + // 외부 DB 테이블 목록 로드 useEffect(() => { - if (selectedDbSource === "internal" || !selectedDbSource) { + // REST API인 경우 테이블 목록 로드 불필요 + if (selectedDbSource === "internal" || !selectedDbSource || selectedDbSource.startsWith("restapi_")) { setExternalTableList([]); return; } - const loadExternalTables = async () => { - try { - setLoadingExternalTables(true); - const token = localStorage.getItem("authToken"); + // 외부 DB인 경우 + if (selectedDbSource.startsWith("external_db_")) { + const connectionId = selectedDbSource.replace("external_db_", ""); + + const loadExternalTables = async () => { + try { + setLoadingExternalTables(true); + const token = localStorage.getItem("authToken"); - const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const response = await fetch(`/api/multi-connection/connections/${connectionId}/tables`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); - if (response && response.ok) { - const data = await response.json(); - if (data.success && data.data) { - const tables = Array.isArray(data.data) ? data.data : []; - const tableNames = tables - .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => - typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, - ) - .filter(Boolean); - setExternalTableList(tableNames); + if (response && response.ok) { + const data = await response.json(); + if (data.success && data.data) { + const tables = Array.isArray(data.data) ? data.data : []; + const tableNames = tables + .map((t: string | { tableName?: string; table_name?: string; tablename?: string; name?: string }) => + typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, + ) + .filter(Boolean); + setExternalTableList(tableNames); + } else { + setExternalTableList([]); + } } else { setExternalTableList([]); } - } else { + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); setExternalTableList([]); + } finally { + setLoadingExternalTables(false); } - } catch (error) { - console.error("외부 DB 테이블 목록 조회 오류:", error); - setExternalTableList([]); - } finally { - setLoadingExternalTables(false); - } - }; + }; - loadExternalTables(); + loadExternalTables(); + } }, [selectedDbSource]); // 플로우 생성 const handleCreate = async () => { console.log("🚀 handleCreate called with formData:", formData); - if (!formData.name || !formData.tableName) { - console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName }); + // REST API인 경우 테이블 이름 검증 스킵 + const isRestApi = selectedDbSource.startsWith("restapi_"); + + if (!formData.name || (!isRestApi && !formData.tableName)) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi }); toast({ title: "입력 오류", - description: "플로우 이름과 테이블 이름은 필수입니다.", + description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", + variant: "destructive", + }); + return; + } + + // REST API인 경우 엔드포인트 검증 + if (isRestApi && !restApiEndpoint) { + toast({ + title: "입력 오류", + description: "REST API 엔드포인트는 필수입니다.", variant: "destructive", }); return; } try { - // DB 소스 정보 추가 - const requestData = { + // 데이터 소스 타입 및 ID 파싱 + let dbSourceType: "internal" | "external" | "restapi" = "internal"; + let dbConnectionId: number | undefined = undefined; + let restApiConnectionId: number | undefined = undefined; + + if (selectedDbSource === "internal") { + dbSourceType = "internal"; + } else if (selectedDbSource.startsWith("external_db_")) { + dbSourceType = "external"; + dbConnectionId = parseInt(selectedDbSource.replace("external_db_", "")); + } else if (selectedDbSource.startsWith("restapi_")) { + dbSourceType = "restapi"; + restApiConnectionId = parseInt(selectedDbSource.replace("restapi_", "")); + } + + // 요청 데이터 구성 + const requestData: Record = { ...formData, - dbSourceType: selectedDbSource === "internal" ? "internal" : "external", - dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource), + dbSourceType, + dbConnectionId, }; + // REST API인 경우 추가 정보 + if (dbSourceType === "restapi") { + requestData.restApiConnectionId = restApiConnectionId; + requestData.restApiEndpoint = restApiEndpoint; + requestData.restApiJsonPath = restApiJsonPath || "data"; + // REST API는 가상 테이블명 사용 + requestData.tableName = `_restapi_${restApiConnectionId}`; + } + console.log("✅ Calling createFlowDefinition with:", requestData); - const response = await createFlowDefinition(requestData); + const response = await createFlowDefinition(requestData as Parameters[0]); if (response.success && response.data) { toast({ title: "생성 완료", @@ -212,6 +276,8 @@ export default function FlowManagementPage() { setIsCreateDialogOpen(false); setFormData({ name: "", description: "", tableName: "" }); setSelectedDbSource("internal"); + setRestApiEndpoint(""); + setRestApiJsonPath("data"); loadFlows(); } else { toast({ @@ -415,125 +481,186 @@ export default function FlowManagementPage() { />
- {/* DB 소스 선택 */} + {/* 데이터 소스 선택 */}
- +

- 플로우에서 사용할 데이터베이스를 선택합니다 + 플로우에서 사용할 데이터 소스를 선택합니다

- {/* 테이블 선택 */} -
- - - - - - - - - - 테이블을 찾을 수 없습니다. - - {selectedDbSource === "internal" - ? // 내부 DB 테이블 목록 - tableList.map((table) => ( - { - console.log("📝 Internal table selected:", { - tableName: table.tableName, - currentValue, - }); - setFormData({ ...formData, tableName: currentValue }); - setOpenTableCombobox(false); - }} - className="text-xs sm:text-sm" - > - -
- {table.displayName || table.tableName} - {table.description && ( - {table.description} - )} -
-
- )) - : // 외부 DB 테이블 목록 - externalTableList.map((tableName, index) => ( - { - setFormData({ ...formData, tableName: currentValue }); - setOpenTableCombobox(false); - }} - className="text-xs sm:text-sm" - > - -
{tableName}
-
- ))} -
-
-
-
-
-

- 플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다) -

-
+ {/* REST API인 경우 엔드포인트 설정 */} + {selectedDbSource.startsWith("restapi_") ? ( + <> +
+ + setRestApiEndpoint(e.target.value)} + placeholder="예: /api/data/list" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 데이터를 조회할 API 엔드포인트 경로입니다 +

+
+
+ + setRestApiJsonPath(e.target.value)} + placeholder="예: data 또는 result.items" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 응답 JSON에서 데이터 배열의 경로입니다 (기본: data) +

+
+ + ) : ( + /* 테이블 선택 (내부 DB 또는 외부 DB) */ +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {selectedDbSource === "internal" + ? // 내부 DB 테이블 목록 + tableList.map((table) => ( + { + console.log("📝 Internal table selected:", { + tableName: table.tableName, + currentValue, + }); + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.displayName || table.tableName} + {table.description && ( + {table.description} + )} +
+
+ )) + : // 외부 DB 테이블 목록 + externalTableList.map((tableName, index) => ( + { + setFormData({ ...formData, tableName: currentValue }); + setOpenTableCombobox(false); + }} + className="text-xs sm:text-sm" + > + +
{tableName}
+
+ ))} +
+
+
+
+
+

+ 플로우의 모든 단계에서 사용할 기본 테이블입니다 (단계마다 상태 컬럼만 지정합니다) +

+
+ )}
+ + {/* 데이터 소스 타입 선택 */}
- - - - - - - - - - - 테이블을 찾을 수 없습니다. - - - {tables.map((table) => ( - { - setEditFormData({ ...editFormData, tableName: table.tableName }); - setTableComboboxOpen(false); - }} - className="text-xs sm:text-sm" - > - -
- {table.tableLabel} - {table.tableName} -
-
- ))} -
-
-
-
-
+ +
+ + {/* 데이터베이스 선택 (database 타입인 경우) */} + {editFormData.dataSourceType === "database" && ( +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + setEditFormData({ ...editFormData, tableName: table.tableName }); + setTableComboboxOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.tableLabel} + {table.tableName} +
+
+ ))} +
+
+
+
+
+
+ )} + + {/* REST API 선택 (restapi 타입인 경우) */} + {editFormData.dataSourceType === "restapi" && ( + <> +
+ + + + + + + + + + + 연결을 찾을 수 없습니다. + + + {editRestApiConnections.map((conn) => ( + { + setEditFormData({ ...editFormData, restApiConnectionId: conn.id || null }); + setEditRestApiComboboxOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {conn.connection_name} + {conn.base_url} +
+
+ ))} +
+
+
+
+
+
+ +
+ + setEditFormData({ ...editFormData, restApiEndpoint: e.target.value })} + placeholder="예: /api/data/list" + /> +

+ 데이터를 조회할 API 엔드포인트 경로입니다 +

+
+ +
+ + setEditFormData({ ...editFormData, restApiJsonPath: e.target.value })} + placeholder="예: data 또는 result.items" + /> +

+ 응답 JSON에서 데이터 배열의 경로입니다 (기본: data) +

+
+ + )} +