From 4b06c6f83a89e6ae6c3d4afd4aac8248b4ac0769 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 1 Dec 2025 15:04:52 +0900 Subject: [PATCH 01/14] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B7=B0=EC=96=B4=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94(?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=B2=98=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/dashboard/DashboardViewer.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index d26ac0b7..b9346ba7 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -545,8 +545,8 @@ export function DashboardViewer({ {/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
- {/* 다운로드 버튼 */} -
+ {/* 다운로드 버튼 - 비활성화 */} + {/*
+
*/}
- {/* 다운로드 버튼 */} -
+ {/* 다운로드 버튼 - 비활성화 */} + {/*
+
*/}
{sortedElements.map((element) => ( From fb16e224f02e3831455391eb2671abc9055ae802 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 1 Dec 2025 18:39:01 +0900 Subject: [PATCH 02/14] =?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 9c3f1d26adb08f5d87869225fa4b49f43681b0d5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 1 Dec 2025 18:41:02 +0900 Subject: [PATCH 03/14] =?UTF-8?q?=EC=B0=A8=EB=9F=89=EA=B4=80=EB=A6=AC(?= =?UTF-8?q?=EA=B8=B0=EC=B4=88=EB=8D=B0=EC=9D=B4=ED=84=B0)=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/authController.ts | 65 ++++ .../src/controllers/driverController.ts | 301 ++++++++++++++++++ backend-node/src/routes/authRoutes.ts | 6 + backend-node/src/routes/driverRoutes.ts | 36 +++ backend-node/src/services/authService.ts | 126 ++++++++ frontend/components/layout/AppLayout.tsx | 13 + frontend/components/layout/ProfileModal.tsx | 143 ++++++++- frontend/hooks/useProfile.ts | 133 +++++++- frontend/lib/api/driver.ts | 92 ++++++ 10 files changed, 914 insertions(+), 3 deletions(-) create mode 100644 backend-node/src/controllers/driverController.ts create mode 100644 backend-node/src/routes/driverRoutes.ts create mode 100644 frontend/lib/api/driver.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 87470dd6..3b5e74da 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 driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -237,6 +238,7 @@ app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 +app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 374015ee..6f72eb10 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -384,4 +384,69 @@ export class AuthController { }); } } + + /** + * POST /api/auth/signup + * 공차중계 회원가입 API + */ + static async signup(req: Request, res: Response): Promise { + try { + const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body; + + logger.info(`=== 공차중계 회원가입 API 호출 ===`); + logger.info(`userId: ${userId}, vehicleNumber: ${vehicleNumber}`); + + // 입력값 검증 + if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) { + res.status(400).json({ + success: false, + message: "필수 입력값이 누락되었습니다.", + error: { + code: "INVALID_INPUT", + details: "아이디, 비밀번호, 이름, 연락처, 면허번호, 차량번호는 필수입니다.", + }, + }); + return; + } + + // 회원가입 처리 + const signupResult = await AuthService.signupDriver({ + userId, + password, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + vehicleType, + }); + + if (signupResult.success) { + logger.info(`공차중계 회원가입 성공: ${userId}`); + res.status(201).json({ + success: true, + message: "회원가입이 완료되었습니다.", + }); + } else { + logger.warn(`공차중계 회원가입 실패: ${userId} - ${signupResult.message}`); + res.status(400).json({ + success: false, + message: signupResult.message || "회원가입에 실패했습니다.", + error: { + code: "SIGNUP_FAILED", + details: signupResult.message, + }, + }); + } + } catch (error) { + logger.error("공차중계 회원가입 API 오류:", error); + res.status(500).json({ + success: false, + message: "회원가입 처리 중 오류가 발생했습니다.", + error: { + code: "SIGNUP_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }); + } + } } diff --git a/backend-node/src/controllers/driverController.ts b/backend-node/src/controllers/driverController.ts new file mode 100644 index 00000000..61a8a010 --- /dev/null +++ b/backend-node/src/controllers/driverController.ts @@ -0,0 +1,301 @@ +// 공차중계 운전자 컨트롤러 +import { Request, Response } from "express"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +export class DriverController { + /** + * GET /api/driver/profile + * 운전자 프로필 조회 + */ + static async getProfile(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 사용자 정보 조회 + const userResult = await query( + `SELECT + user_id, user_name, cell_phone, license_number, vehicle_number, signup_type + FROM user_info + WHERE user_id = $1`, + [userId] + ); + + if (userResult.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + const user = userResult[0]; + + // 공차중계 사용자가 아닌 경우 + if (user.signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // 차량 정보 조회 + const vehicleResult = await query( + `SELECT + vehicle_number, vehicle_type, driver_name, driver_phone, status + FROM vehicles + WHERE user_id = $1`, + [userId] + ); + + const vehicle = vehicleResult.length > 0 ? vehicleResult[0] : null; + + res.status(200).json({ + success: true, + data: { + userId: user.user_id, + userName: user.user_name, + phoneNumber: user.cell_phone, + licenseNumber: user.license_number, + vehicleNumber: user.vehicle_number, + vehicleType: vehicle?.vehicle_type || null, + vehicleStatus: vehicle?.status || null, + }, + }); + } catch (error) { + logger.error("운전자 프로필 조회 오류:", error); + res.status(500).json({ + success: false, + message: "프로필 조회 중 오류가 발생했습니다.", + }); + } + } + + /** + * PUT /api/driver/profile + * 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종) + */ + static async updateProfile(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body; + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + if (userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + const oldVehicleNumber = userCheck[0].vehicle_number; + + // 차량번호 변경 시 중복 확인 + if (vehicleNumber && vehicleNumber !== oldVehicleNumber) { + const duplicateCheck = await query( + `SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id != $2`, + [vehicleNumber, userId] + ); + + if (duplicateCheck.length > 0) { + res.status(400).json({ + success: false, + message: "이미 등록된 차량번호입니다.", + }); + return; + } + } + + // user_info 업데이트 + await query( + `UPDATE user_info SET + user_name = COALESCE($1, user_name), + cell_phone = COALESCE($2, cell_phone), + license_number = COALESCE($3, license_number), + vehicle_number = COALESCE($4, vehicle_number) + WHERE user_id = $5`, + [userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, userId] + ); + + // vehicles 테이블 업데이트 + await query( + `UPDATE vehicles SET + vehicle_number = COALESCE($1, vehicle_number), + vehicle_type = COALESCE($2, vehicle_type), + driver_name = COALESCE($3, driver_name), + driver_phone = COALESCE($4, driver_phone), + updated_at = NOW() + WHERE user_id = $5`, + [vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, userId] + ); + + logger.info(`운전자 프로필 수정 완료: ${userId}`); + + res.status(200).json({ + success: true, + message: "프로필이 수정되었습니다.", + }); + } catch (error) { + logger.error("운전자 프로필 수정 오류:", error); + res.status(500).json({ + success: false, + message: "프로필 수정 중 오류가 발생했습니다.", + }); + } + } + + /** + * PUT /api/driver/status + * 차량 상태 변경 (대기/정비만 가능) + */ + static async updateStatus(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + const { status } = req.body; + + // 허용된 상태값만 (대기: off, 정비: maintenance) + const allowedStatuses = ["off", "maintenance"]; + if (!status || !allowedStatuses.includes(status)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 상태값입니다. (off: 대기, maintenance: 정비)", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // vehicles 테이블 상태 업데이트 + const updateResult = await query( + `UPDATE vehicles SET status = $1, updated_at = NOW() WHERE user_id = $2`, + [status, userId] + ); + + logger.info(`차량 상태 변경: ${userId} -> ${status}`); + + res.status(200).json({ + success: true, + message: `차량 상태가 ${status === "off" ? "대기" : "정비"}로 변경되었습니다.`, + }); + } catch (error) { + logger.error("차량 상태 변경 오류:", error); + res.status(500).json({ + success: false, + message: "상태 변경 중 오류가 발생했습니다.", + }); + } + } + + /** + * DELETE /api/driver/account + * 회원 탈퇴 (차량 정보 포함 삭제) + */ + static async deleteAccount(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + if (userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 탈퇴할 수 있습니다.", + }); + return; + } + + // vehicles 테이블에서 삭제 + await query(`DELETE FROM vehicles WHERE user_id = $1`, [userId]); + + // user_info 테이블에서 삭제 + await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]); + + logger.info(`회원 탈퇴 완료: ${userId}`); + + res.status(200).json({ + success: true, + message: "회원 탈퇴가 완료되었습니다.", + }); + } catch (error) { + logger.error("회원 탈퇴 오류:", error); + res.status(500).json({ + success: false, + message: "회원 탈퇴 처리 중 오류가 발생했습니다.", + }); + } + } +} + diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts index 29bc7944..adba86e6 100644 --- a/backend-node/src/routes/authRoutes.ts +++ b/backend-node/src/routes/authRoutes.ts @@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout); */ router.post("/refresh", AuthController.refreshToken); +/** + * POST /api/auth/signup + * 공차중계 회원가입 API + */ +router.post("/signup", AuthController.signup); + export default router; diff --git a/backend-node/src/routes/driverRoutes.ts b/backend-node/src/routes/driverRoutes.ts new file mode 100644 index 00000000..29a68244 --- /dev/null +++ b/backend-node/src/routes/driverRoutes.ts @@ -0,0 +1,36 @@ +// 공차중계 운전자 API 라우터 +import { Router } from "express"; +import { DriverController } from "../controllers/driverController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 필요 +router.use(authenticateToken); + +/** + * GET /api/driver/profile + * 운전자 프로필 조회 + */ +router.get("/profile", DriverController.getProfile); + +/** + * PUT /api/driver/profile + * 운전자 프로필 수정 (이름, 연락처, 면허정보, 차량번호, 차종) + */ +router.put("/profile", DriverController.updateProfile); + +/** + * PUT /api/driver/status + * 차량 상태 변경 (대기/정비만) + */ +router.put("/status", DriverController.updateStatus); + +/** + * DELETE /api/driver/account + * 회원 탈퇴 + */ +router.delete("/account", DriverController.deleteAccount); + +export default router; + diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 11e34576..e5d6aa97 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -342,4 +342,130 @@ export class AuthService { ); } } + + /** + * 공차중계 회원가입 처리 + * - user_info 테이블에 사용자 정보 저장 + * - vehicles 테이블에 차량 정보 저장 + */ + static async signupDriver(data: { + userId: string; + password: string; + userName: string; + phoneNumber: string; + licenseNumber: string; + vehicleNumber: string; + vehicleType?: string; + }): Promise<{ success: boolean; message?: string }> { + try { + const { + userId, + password, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + vehicleType, + } = data; + + // 1. 중복 사용자 확인 + const existingUser = await query( + `SELECT user_id FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (existingUser.length > 0) { + return { + success: false, + message: "이미 존재하는 아이디입니다.", + }; + } + + // 2. 중복 차량번호 확인 + const existingVehicle = await query( + `SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1`, + [vehicleNumber] + ); + + if (existingVehicle.length > 0) { + return { + success: false, + message: "이미 등록된 차량번호입니다.", + }; + } + + // 3. 비밀번호 암호화 (MD5 - 기존 시스템 호환) + const crypto = require("crypto"); + const hashedPassword = crypto + .createHash("md5") + .update(password) + .digest("hex"); + + // 4. 사용자 정보 저장 (user_info) + await query( + `INSERT INTO user_info ( + user_id, + user_password, + user_name, + cell_phone, + license_number, + vehicle_number, + company_code, + user_type, + signup_type, + status, + regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`, + [ + userId, + hashedPassword, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + "COMPANY_13", // 기본 회사 코드 + null, // user_type: null + "DRIVER", // signup_type: 공차중계 회원가입 사용자 + "active", // status: active + ] + ); + + // 5. 차량 정보 저장 (vehicles) + await query( + `INSERT INTO vehicles ( + vehicle_number, + vehicle_type, + driver_name, + driver_phone, + status, + company_code, + user_id, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`, + [ + vehicleNumber, + vehicleType || null, + userName, + phoneNumber, + "off", // 초기 상태: off (대기) + "COMPANY_13", // 기본 회사 코드 + userId, // 사용자 ID 연결 + ] + ); + + logger.info(`공차중계 회원가입 성공: ${userId}, 차량번호: ${vehicleNumber}`); + + return { + success: true, + message: "회원가입이 완료되었습니다.", + }; + } catch (error: any) { + logger.error("공차중계 회원가입 오류:", error); + return { + success: false, + message: error.message || "회원가입 중 오류가 발생했습니다.", + }; + } + } } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 8394cd6d..51b939a4 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -234,6 +234,13 @@ function AppLayoutInner({ children }: AppLayoutProps) { selectImage, removeImage, saveProfile, + // 운전자 관련 + isDriver, + driverInfo, + driverFormData, + updateDriverFormData, + handleDriverStatusChange, + handleDriverAccountDelete, } = useProfile(user, refreshUserData, refreshMenus); // 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려) @@ -483,6 +490,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { isSaving={isSaving} departments={departments} alertModal={alertModal} + isDriver={isDriver} + driverInfo={driverInfo} + driverFormData={driverFormData} + onDriverFormChange={updateDriverFormData} + onDriverStatusChange={handleDriverStatusChange} + onDriverAccountDelete={handleDriverAccountDelete} onClose={closeProfileModal} onFormChange={updateFormData} onImageSelect={selectImage} diff --git a/frontend/components/layout/ProfileModal.tsx b/frontend/components/layout/ProfileModal.tsx index 9dce16a0..8fd2190a 100644 --- a/frontend/components/layout/ProfileModal.tsx +++ b/frontend/components/layout/ProfileModal.tsx @@ -11,8 +11,18 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Camera, X } from "lucide-react"; +import { Camera, X, Car, Wrench, Clock } from "lucide-react"; import { ProfileFormData } from "@/types/profile"; +import { Separator } from "@/components/ui/separator"; + +// 운전자 정보 타입 +export interface DriverInfo { + vehicleNumber: string; + vehicleType: string | null; + licenseNumber: string; + phoneNumber: string; + vehicleStatus: string | null; +} // 알림 모달 컴포넌트 interface AlertModalProps { @@ -54,6 +64,14 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod ); } +// 운전자 폼 데이터 타입 +export interface DriverFormData { + vehicleNumber: string; + vehicleType: string; + licenseNumber: string; + phoneNumber: string; +} + interface ProfileModalProps { isOpen: boolean; user: any; @@ -70,6 +88,13 @@ interface ProfileModalProps { message: string; type: "success" | "error" | "info"; }; + // 운전자 관련 props (선택적) + isDriver?: boolean; + driverInfo?: DriverInfo | null; + driverFormData?: DriverFormData; + onDriverFormChange?: (field: keyof DriverFormData, value: string) => void; + onDriverStatusChange?: (status: "off" | "maintenance") => void; + onDriverAccountDelete?: () => void; onClose: () => void; onFormChange: (field: keyof ProfileFormData, value: string) => void; onImageSelect: (event: React.ChangeEvent) => void; @@ -89,6 +114,12 @@ export function ProfileModal({ isSaving, departments, alertModal, + isDriver = false, + driverInfo, + driverFormData, + onDriverFormChange, + onDriverStatusChange, + onDriverAccountDelete, onClose, onFormChange, onImageSelect, @@ -96,6 +127,21 @@ export function ProfileModal({ onSave, onAlertClose, }: ProfileModalProps) { + // 차량 상태 한글 변환 + const getStatusLabel = (status: string | null) => { + switch (status) { + case "off": + return "대기"; + case "active": + return "운행중"; + case "inactive": + return "공차"; + case "maintenance": + return "정비"; + default: + return status || "-"; + } + }; return ( <> @@ -234,6 +280,101 @@ export function ProfileModal({
+ + {/* 운전자 정보 섹션 (공차중계 사용자만) */} + {isDriver && driverFormData && onDriverFormChange && ( + <> + +
+
+ +

차량/운전자 정보

+
+ +
+
+ + onDriverFormChange("vehicleNumber", e.target.value)} + placeholder="12가1234" + /> +
+
+ + onDriverFormChange("vehicleType", e.target.value)} + placeholder="1톤 카고" + /> +
+
+ +
+
+ + onDriverFormChange("phoneNumber", e.target.value)} + placeholder="010-1234-5678" + /> +
+
+ + onDriverFormChange("licenseNumber", e.target.value)} + placeholder="12-34-567890-12" + /> +
+
+ + {/* 차량 상태 */} + {driverInfo && onDriverStatusChange && ( +
+ +
+ + {getStatusLabel(driverInfo.vehicleStatus)} + +
+ + +
+
+

+ * 운행/공차 상태는 공차등록 화면에서 변경하세요 +

+
+ )} + +
+ + )}
diff --git a/frontend/hooks/useProfile.ts b/frontend/hooks/useProfile.ts index 0498eb13..bae5e9d7 100644 --- a/frontend/hooks/useProfile.ts +++ b/frontend/hooks/useProfile.ts @@ -4,6 +4,14 @@ import { useState, useCallback, useEffect } from "react"; import { ProfileFormData, ProfileModalState } from "@/types/profile"; import { LAYOUT_CONFIG, MESSAGES } from "@/constants/layout"; import { apiCall } from "@/lib/api/client"; +import { + getDriverProfile, + updateDriverProfile, + updateDriverStatus, + deleteDriverAccount, + DriverProfile, +} from "@/lib/api/driver"; +import { DriverInfo, DriverFormData } from "@/components/layout/ProfileModal"; // 알림 모달 상태 타입 interface AlertModalState { @@ -48,6 +56,16 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr }> >([]); + // 운전자 정보 상태 + const [isDriver, setIsDriver] = useState(false); + const [driverInfo, setDriverInfo] = useState(null); + const [driverFormData, setDriverFormData] = useState({ + vehicleNumber: "", + vehicleType: "", + licenseNumber: "", + phoneNumber: "", + }); + // 알림 모달 표시 함수 const showAlert = useCallback((title: string, message: string, type: "success" | "error" | "info" = "info") => { setAlertModal({ @@ -75,6 +93,35 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr } }, []); + // 운전자 정보 로드 함수 + const loadDriverInfo = useCallback(async () => { + try { + const response = await getDriverProfile(); + if (response.success && response.data) { + setIsDriver(true); + setDriverInfo({ + vehicleNumber: response.data.vehicleNumber, + vehicleType: response.data.vehicleType, + licenseNumber: response.data.licenseNumber, + phoneNumber: response.data.phoneNumber, + vehicleStatus: response.data.vehicleStatus, + }); + setDriverFormData({ + vehicleNumber: response.data.vehicleNumber || "", + vehicleType: response.data.vehicleType || "", + licenseNumber: response.data.licenseNumber || "", + phoneNumber: response.data.phoneNumber || "", + }); + } else { + setIsDriver(false); + setDriverInfo(null); + } + } catch (error) { + console.error("운전자 정보 로드 실패:", error); + setIsDriver(false); + } + }, []); + /** * 프로필 모달 열기 */ @@ -82,6 +129,8 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr if (user) { // 부서 목록 로드 loadDepartments(); + // 운전자 정보 로드 + loadDriverInfo(); setModalState((prev) => ({ ...prev, @@ -98,7 +147,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr isSaving: false, })); } - }, [user, loadDepartments]); + }, [user, loadDepartments, loadDriverInfo]); /** * 프로필 모달 닫기 @@ -125,6 +174,61 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr })); }, []); + /** + * 운전자 폼 데이터 변경 + */ + const updateDriverFormData = useCallback((field: keyof DriverFormData, value: string) => { + setDriverFormData((prev) => ({ + ...prev, + [field]: value, + })); + }, []); + + /** + * 차량 상태 변경 (대기/정비) + */ + const handleDriverStatusChange = useCallback( + async (status: "off" | "maintenance") => { + try { + const response = await updateDriverStatus(status); + if (response.success) { + showAlert("상태 변경", response.message || "차량 상태가 변경되었습니다.", "success"); + // 운전자 정보 새로고침 + await loadDriverInfo(); + } else { + showAlert("상태 변경 실패", response.message || "상태 변경에 실패했습니다.", "error"); + } + } catch (error) { + console.error("차량 상태 변경 실패:", error); + showAlert("오류", "상태 변경 중 오류가 발생했습니다.", "error"); + } + }, + [showAlert, loadDriverInfo] + ); + + /** + * 회원 탈퇴 + */ + const handleDriverAccountDelete = useCallback(async () => { + if (!confirm("정말로 탈퇴하시겠습니까?\n차량 정보가 함께 삭제되며, 이 작업은 되돌릴 수 없습니다.")) { + return; + } + + try { + const response = await deleteDriverAccount(); + if (response.success) { + showAlert("탈퇴 완료", "회원 탈퇴가 완료되었습니다.", "success"); + // 로그아웃 처리 + window.location.href = "/login"; + } else { + showAlert("탈퇴 실패", response.message || "회원 탈퇴에 실패했습니다.", "error"); + } + } catch (error) { + console.error("회원 탈퇴 실패:", error); + showAlert("오류", "회원 탈퇴 중 오류가 발생했습니다.", "error"); + } + }, [showAlert]); + /** * 이미지 선택 처리 */ @@ -229,6 +333,21 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr // API 호출 (JWT 토큰 자동 포함) const response = await apiCall("PUT", "/admin/profile", updateData); + // 운전자 정보도 저장 (운전자인 경우) + if (isDriver) { + const driverResponse = await updateDriverProfile({ + userName: modalState.formData.userName, + phoneNumber: driverFormData.phoneNumber, + licenseNumber: driverFormData.licenseNumber, + vehicleNumber: driverFormData.vehicleNumber, + vehicleType: driverFormData.vehicleType, + }); + + if (!driverResponse.success) { + console.warn("운전자 정보 저장 실패:", driverResponse.message); + } + } + if (response.success || (response as any).result) { // locale이 변경된 경우 전역 변수와 localStorage 업데이트 const localeChanged = modalState.formData.locale && modalState.formData.locale !== user.locale; @@ -265,7 +384,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr } finally { setModalState((prev) => ({ ...prev, isSaving: false })); } - }, [user, modalState.selectedFile, modalState.selectedImage, modalState.formData, refreshUserData, showAlert]); + }, [user, modalState.selectedFile, modalState.selectedImage, modalState.formData, refreshUserData, showAlert, isDriver, driverFormData]); return { // 상태 @@ -279,6 +398,11 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr alertModal, closeAlert, + // 운전자 관련 상태 + isDriver, + driverInfo, + driverFormData, + // 액션 openProfileModal, closeProfileModal, @@ -286,5 +410,10 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr selectImage, removeImage, saveProfile, + + // 운전자 관련 액션 + updateDriverFormData, + handleDriverStatusChange, + handleDriverAccountDelete, }; }; diff --git a/frontend/lib/api/driver.ts b/frontend/lib/api/driver.ts new file mode 100644 index 00000000..8600c5cb --- /dev/null +++ b/frontend/lib/api/driver.ts @@ -0,0 +1,92 @@ +// 공차중계 운전자 API +import { apiClient } from "./client"; + +export interface DriverProfile { + userId: string; + userName: string; + phoneNumber: string; + licenseNumber: string; + vehicleNumber: string; + vehicleType: string | null; + vehicleStatus: string | null; +} + +export interface DriverProfileUpdateData { + userName?: string; + phoneNumber?: string; + licenseNumber?: string; + vehicleNumber?: string; + vehicleType?: string; +} + +/** + * 운전자 프로필 조회 + */ +export async function getDriverProfile(): Promise<{ + success: boolean; + data?: DriverProfile; + message?: string; +}> { + try { + const response = await apiClient.get("/driver/profile"); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "프로필 조회에 실패했습니다.", + }; + } +} + +/** + * 운전자 프로필 수정 + */ +export async function updateDriverProfile( + data: DriverProfileUpdateData +): Promise<{ success: boolean; message?: string }> { + try { + const response = await apiClient.put("/driver/profile", data); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "프로필 수정에 실패했습니다.", + }; + } +} + +/** + * 차량 상태 변경 (대기/정비) + */ +export async function updateDriverStatus( + status: "off" | "maintenance" +): Promise<{ success: boolean; message?: string }> { + try { + const response = await apiClient.put("/driver/status", { status }); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "상태 변경에 실패했습니다.", + }; + } +} + +/** + * 회원 탈퇴 + */ +export async function deleteDriverAccount(): Promise<{ + success: boolean; + message?: string; +}> { + try { + const response = await apiClient.delete("/driver/account"); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "회원 탈퇴에 실패했습니다.", + }; + } +} + From cd47f569e2f4b850617349429209d852448a336b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 1 Dec 2025 19:03:43 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EA=B3=B5=EC=B0=A8=EC=A4=91?= =?UTF-8?q?=EA=B3=84=20=EC=9A=B4=EC=A0=84=EC=9E=90=20=EC=B0=A8=EB=9F=89/?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/driverController.ts | 171 ++++++++++- backend-node/src/routes/driverRoutes.ts | 12 + frontend/components/layout/AppLayout.tsx | 16 + frontend/components/layout/ProfileModal.tsx | 280 +++++++++++++----- frontend/hooks/useProfile.ts | 107 +++++++ frontend/lib/api/driver.ts | 43 +++ 6 files changed, 540 insertions(+), 89 deletions(-) diff --git a/backend-node/src/controllers/driverController.ts b/backend-node/src/controllers/driverController.ts index 61a8a010..a448d9c0 100644 --- a/backend-node/src/controllers/driverController.ts +++ b/backend-node/src/controllers/driverController.ts @@ -23,7 +23,7 @@ export class DriverController { // 사용자 정보 조회 const userResult = await query( `SELECT - user_id, user_name, cell_phone, license_number, vehicle_number, signup_type + user_id, user_name, cell_phone, license_number, vehicle_number, signup_type, branch_name FROM user_info WHERE user_id = $1`, [userId] @@ -69,6 +69,7 @@ export class DriverController { vehicleNumber: user.vehicle_number, vehicleType: vehicle?.vehicle_type || null, vehicleStatus: vehicle?.status || null, + branchName: user.branch_name || null, }, }); } catch (error) { @@ -96,7 +97,7 @@ export class DriverController { return; } - const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType } = req.body; + const { userName, phoneNumber, licenseNumber, vehicleNumber, vehicleType, branchName } = req.body; // 공차중계 사용자 확인 const userCheck = await query( @@ -144,9 +145,10 @@ export class DriverController { user_name = COALESCE($1, user_name), cell_phone = COALESCE($2, cell_phone), license_number = COALESCE($3, license_number), - vehicle_number = COALESCE($4, vehicle_number) - WHERE user_id = $5`, - [userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, userId] + vehicle_number = COALESCE($4, vehicle_number), + branch_name = COALESCE($5, branch_name) + WHERE user_id = $6`, + [userName || null, phoneNumber || null, licenseNumber || null, vehicleNumber || null, branchName || null, userId] ); // vehicles 테이블 업데이트 @@ -156,9 +158,10 @@ export class DriverController { vehicle_type = COALESCE($2, vehicle_type), driver_name = COALESCE($3, driver_name), driver_phone = COALESCE($4, driver_phone), + branch_name = COALESCE($5, branch_name), updated_at = NOW() - WHERE user_id = $5`, - [vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, userId] + WHERE user_id = $6`, + [vehicleNumber || null, vehicleType || null, userName || null, phoneNumber || null, branchName || null, userId] ); logger.info(`운전자 프로필 수정 완료: ${userId}`); @@ -239,6 +242,160 @@ export class DriverController { } } + /** + * DELETE /api/driver/vehicle + * 차량 삭제 (user_id = NULL 처리, 기록 보존) + */ + static async deleteVehicle(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type, vehicle_number FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // vehicles 테이블에서 user_id를 NULL로 변경하고 status를 disabled로 (기록 보존) + await query( + `UPDATE vehicles SET user_id = NULL, status = 'disabled', updated_at = NOW() WHERE user_id = $1`, + [userId] + ); + + // user_info에서 vehicle_number를 NULL로 변경 + await query( + `UPDATE user_info SET vehicle_number = NULL WHERE user_id = $1`, + [userId] + ); + + logger.info(`차량 삭제 완료 (기록 보존): ${userId}`); + + res.status(200).json({ + success: true, + message: "차량이 삭제되었습니다.", + }); + } catch (error) { + logger.error("차량 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "차량 삭제 중 오류가 발생했습니다.", + }); + } + } + + /** + * POST /api/driver/vehicle + * 새 차량 등록 + */ + static async registerVehicle(req: Request, res: Response): Promise { + try { + const userId = req.user?.userId; + const companyCode = req.user?.companyCode; + + if (!userId) { + res.status(401).json({ + success: false, + message: "인증이 필요합니다.", + }); + return; + } + + const { vehicleNumber, vehicleType, branchName } = req.body; + + if (!vehicleNumber) { + res.status(400).json({ + success: false, + message: "차량번호는 필수입니다.", + }); + return; + } + + // 공차중계 사용자 확인 + const userCheck = await query( + `SELECT signup_type, user_name, cell_phone, vehicle_number, company_code FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (userCheck.length === 0 || userCheck[0].signup_type !== "DRIVER") { + res.status(403).json({ + success: false, + message: "공차중계 사용자만 접근할 수 있습니다.", + }); + return; + } + + // 이미 차량이 있는지 확인 + if (userCheck[0].vehicle_number) { + res.status(400).json({ + success: false, + message: "이미 등록된 차량이 있습니다. 먼저 기존 차량을 삭제해주세요.", + }); + return; + } + + // 차량번호 중복 확인 + const duplicateCheck = await query( + `SELECT vehicle_number FROM vehicles WHERE vehicle_number = $1 AND user_id IS NOT NULL`, + [vehicleNumber] + ); + + if (duplicateCheck.length > 0) { + res.status(400).json({ + success: false, + message: "이미 등록된 차량번호입니다.", + }); + return; + } + + const userName = userCheck[0].user_name; + const userPhone = userCheck[0].cell_phone; + // 사용자의 company_code 사용 (req.user에서 가져오거나 DB에서 조회한 값 사용) + const userCompanyCode = companyCode || userCheck[0].company_code; + + // vehicles 테이블에 새 차량 등록 (company_code 포함, status는 'off') + await query( + `INSERT INTO vehicles (vehicle_number, vehicle_type, user_id, driver_name, driver_phone, branch_name, status, company_code, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, 'off', $7, NOW(), NOW())`, + [vehicleNumber, vehicleType || null, userId, userName, userPhone, branchName || null, userCompanyCode] + ); + + // user_info에 vehicle_number 업데이트 + await query( + `UPDATE user_info SET vehicle_number = $1 WHERE user_id = $2`, + [vehicleNumber, userId] + ); + + logger.info(`새 차량 등록 완료: ${userId} -> ${vehicleNumber} (company_code: ${userCompanyCode})`); + + res.status(200).json({ + success: true, + message: "차량이 등록되었습니다.", + }); + } catch (error) { + logger.error("차량 등록 오류:", error); + res.status(500).json({ + success: false, + message: "차량 등록 중 오류가 발생했습니다.", + }); + } + } + /** * DELETE /api/driver/account * 회원 탈퇴 (차량 정보 포함 삭제) diff --git a/backend-node/src/routes/driverRoutes.ts b/backend-node/src/routes/driverRoutes.ts index 29a68244..b46cca1b 100644 --- a/backend-node/src/routes/driverRoutes.ts +++ b/backend-node/src/routes/driverRoutes.ts @@ -26,6 +26,18 @@ router.put("/profile", DriverController.updateProfile); */ router.put("/status", DriverController.updateStatus); +/** + * DELETE /api/driver/vehicle + * 차량 삭제 (기록 보존) + */ +router.delete("/vehicle", DriverController.deleteVehicle); + +/** + * POST /api/driver/vehicle + * 새 차량 등록 + */ +router.post("/vehicle", DriverController.registerVehicle); + /** * DELETE /api/driver/account * 회원 탈퇴 diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 51b939a4..318266da 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -236,11 +236,19 @@ function AppLayoutInner({ children }: AppLayoutProps) { saveProfile, // 운전자 관련 isDriver, + hasVehicle, driverInfo, driverFormData, updateDriverFormData, handleDriverStatusChange, handleDriverAccountDelete, + handleDeleteVehicle, + openVehicleRegisterModal, + closeVehicleRegisterModal, + isVehicleRegisterModalOpen, + newVehicleData, + updateNewVehicleData, + handleRegisterVehicle, } = useProfile(user, refreshUserData, refreshMenus); // 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려) @@ -491,11 +499,19 @@ function AppLayoutInner({ children }: AppLayoutProps) { departments={departments} alertModal={alertModal} isDriver={isDriver} + hasVehicle={hasVehicle} driverInfo={driverInfo} driverFormData={driverFormData} onDriverFormChange={updateDriverFormData} onDriverStatusChange={handleDriverStatusChange} onDriverAccountDelete={handleDriverAccountDelete} + onDeleteVehicle={handleDeleteVehicle} + onOpenVehicleRegisterModal={openVehicleRegisterModal} + isVehicleRegisterModalOpen={isVehicleRegisterModalOpen} + newVehicleData={newVehicleData} + onCloseVehicleRegisterModal={closeVehicleRegisterModal} + onNewVehicleDataChange={updateNewVehicleData} + onRegisterVehicle={handleRegisterVehicle} onClose={closeProfileModal} onFormChange={updateFormData} onImageSelect={selectImage} diff --git a/frontend/components/layout/ProfileModal.tsx b/frontend/components/layout/ProfileModal.tsx index 8fd2190a..e79d3357 100644 --- a/frontend/components/layout/ProfileModal.tsx +++ b/frontend/components/layout/ProfileModal.tsx @@ -11,9 +11,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Camera, X, Car, Wrench, Clock } from "lucide-react"; +import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react"; import { ProfileFormData } from "@/types/profile"; import { Separator } from "@/components/ui/separator"; +import { VehicleRegisterData } from "@/lib/api/driver"; // 운전자 정보 타입 export interface DriverInfo { @@ -22,6 +23,7 @@ export interface DriverInfo { licenseNumber: string; phoneNumber: string; vehicleStatus: string | null; + branchName: string | null; } // 알림 모달 컴포넌트 @@ -70,6 +72,7 @@ export interface DriverFormData { vehicleType: string; licenseNumber: string; phoneNumber: string; + branchName: string; } interface ProfileModalProps { @@ -90,11 +93,21 @@ interface ProfileModalProps { }; // 운전자 관련 props (선택적) isDriver?: boolean; + hasVehicle?: boolean; driverInfo?: DriverInfo | null; driverFormData?: DriverFormData; onDriverFormChange?: (field: keyof DriverFormData, value: string) => void; onDriverStatusChange?: (status: "off" | "maintenance") => void; onDriverAccountDelete?: () => void; + // 차량 삭제/등록 관련 props + onDeleteVehicle?: () => void; + onOpenVehicleRegisterModal?: () => void; + // 새 차량 등록 모달 관련 props + isVehicleRegisterModalOpen?: boolean; + newVehicleData?: VehicleRegisterData; + onCloseVehicleRegisterModal?: () => void; + onNewVehicleDataChange?: (field: keyof VehicleRegisterData, value: string) => void; + onRegisterVehicle?: () => void; onClose: () => void; onFormChange: (field: keyof ProfileFormData, value: string) => void; onImageSelect: (event: React.ChangeEvent) => void; @@ -115,11 +128,19 @@ export function ProfileModal({ departments, alertModal, isDriver = false, + hasVehicle = false, driverInfo, driverFormData, onDriverFormChange, onDriverStatusChange, onDriverAccountDelete, + onDeleteVehicle, + onOpenVehicleRegisterModal, + isVehicleRegisterModalOpen = false, + newVehicleData, + onCloseVehicleRegisterModal, + onNewVehicleDataChange, + onRegisterVehicle, onClose, onFormChange, onImageSelect, @@ -282,96 +303,147 @@ export function ProfileModal({
{/* 운전자 정보 섹션 (공차중계 사용자만) */} - {isDriver && driverFormData && onDriverFormChange && ( + {isDriver && ( <>
-
- -

차량/운전자 정보

+
+
+ +

차량/운전자 정보

+
+ {/* 차량 유무에 따른 버튼 표시 */} + {hasVehicle ? ( + + ) : ( + + )}
-
-
- - onDriverFormChange("vehicleNumber", e.target.value)} - placeholder="12가1234" - /> -
-
- - onDriverFormChange("vehicleType", e.target.value)} - placeholder="1톤 카고" - /> -
-
+ {/* 운전자 정보 (항상 수정 가능) */} + {driverFormData && onDriverFormChange && ( + <> + {/* 차량 정보 - 차량이 있을 때만 수정 가능 */} + {hasVehicle ? ( +
+
+ + onDriverFormChange("vehicleNumber", e.target.value)} + placeholder="12가1234" + /> +
+
+ + onDriverFormChange("vehicleType", e.target.value)} + placeholder="1톤 카고" + /> +
+
+ ) : ( + /* 차량이 없는 경우: 안내 메시지 */ +
+ +

등록된 차량이 없습니다.

+

새 차량 등록 버튼을 눌러 차량을 등록하세요.

+
+ )} -
-
- - onDriverFormChange("phoneNumber", e.target.value)} - placeholder="010-1234-5678" - /> -
-
- - onDriverFormChange("licenseNumber", e.target.value)} - placeholder="12-34-567890-12" - /> -
-
- - {/* 차량 상태 */} - {driverInfo && onDriverStatusChange && ( -
- -
- - {getStatusLabel(driverInfo.vehicleStatus)} - -
- - + {/* 운전자 개인 정보 - 항상 수정 가능 */} +
+
+ + onDriverFormChange("phoneNumber", e.target.value)} + placeholder="010-1234-5678" + /> +
+
+ + onDriverFormChange("licenseNumber", e.target.value)} + placeholder="12-34-567890-12" + />
-

- * 운행/공차 상태는 공차등록 화면에서 변경하세요 -

-
- )} +
+ + onDriverFormChange("branchName", e.target.value)} + placeholder="서울 본점" + /> +
+ + {/* 차량 상태 - 차량이 있을 때만 표시 */} + {hasVehicle && driverInfo && onDriverStatusChange && ( +
+ +
+ + {getStatusLabel(driverInfo.vehicleStatus)} + +
+ + +
+
+

+ * 운행/공차 상태는 공차등록 화면에서 변경하세요 +

+
+ )} + + )}
)} @@ -396,6 +468,50 @@ export function ProfileModal({ message={alertModal.message} type={alertModal.type} /> + + {/* 새 차량 등록 모달 */} + {isVehicleRegisterModalOpen && newVehicleData && onNewVehicleDataChange && onRegisterVehicle && onCloseVehicleRegisterModal && ( + + + + 새 차량 등록 + + 새로운 차량 정보를 입력해주세요. + + + +
+
+ + onNewVehicleDataChange("vehicleNumber", e.target.value)} + placeholder="12가1234" + /> +
+
+ + onNewVehicleDataChange("vehicleType", e.target.value)} + placeholder="1톤 카고" + /> +
+
+ + + + + +
+
+ )} ); } diff --git a/frontend/hooks/useProfile.ts b/frontend/hooks/useProfile.ts index bae5e9d7..f96593f5 100644 --- a/frontend/hooks/useProfile.ts +++ b/frontend/hooks/useProfile.ts @@ -9,7 +9,10 @@ import { updateDriverProfile, updateDriverStatus, deleteDriverAccount, + deleteDriverVehicle, + registerDriverVehicle, DriverProfile, + VehicleRegisterData, } from "@/lib/api/driver"; import { DriverInfo, DriverFormData } from "@/components/layout/ProfileModal"; @@ -58,12 +61,22 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr // 운전자 정보 상태 const [isDriver, setIsDriver] = useState(false); + const [hasVehicle, setHasVehicle] = useState(false); // 차량 보유 여부 const [driverInfo, setDriverInfo] = useState(null); const [driverFormData, setDriverFormData] = useState({ vehicleNumber: "", vehicleType: "", licenseNumber: "", phoneNumber: "", + branchName: "", + }); + + // 새 차량 등록 모달 상태 + const [isVehicleRegisterModalOpen, setIsVehicleRegisterModalOpen] = useState(false); + const [newVehicleData, setNewVehicleData] = useState({ + vehicleNumber: "", + vehicleType: "", + branchName: "", }); // 알림 모달 표시 함수 @@ -99,21 +112,27 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr const response = await getDriverProfile(); if (response.success && response.data) { setIsDriver(true); + // 차량 보유 여부 확인 + const vehicleExists = !!response.data.vehicleNumber; + setHasVehicle(vehicleExists); setDriverInfo({ vehicleNumber: response.data.vehicleNumber, vehicleType: response.data.vehicleType, licenseNumber: response.data.licenseNumber, phoneNumber: response.data.phoneNumber, vehicleStatus: response.data.vehicleStatus, + branchName: response.data.branchName, }); setDriverFormData({ vehicleNumber: response.data.vehicleNumber || "", vehicleType: response.data.vehicleType || "", licenseNumber: response.data.licenseNumber || "", phoneNumber: response.data.phoneNumber || "", + branchName: response.data.branchName || "", }); } else { setIsDriver(false); + setHasVehicle(false); setDriverInfo(null); } } catch (error) { @@ -229,6 +248,83 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr } }, [showAlert]); + /** + * 차량 삭제 + */ + const handleDeleteVehicle = useCallback(async () => { + if (!confirm("이 차량을 더 이상 사용하지 않습니까?\n차량 정보가 삭제됩니다.")) { + return; + } + + try { + const response = await deleteDriverVehicle(); + if (response.success) { + showAlert("삭제 완료", "차량이 삭제되었습니다.", "success"); + // 운전자 정보 새로고침 + await loadDriverInfo(); + } else { + showAlert("삭제 실패", response.message || "차량 삭제에 실패했습니다.", "error"); + } + } catch (error) { + console.error("차량 삭제 실패:", error); + showAlert("오류", "차량 삭제 중 오류가 발생했습니다.", "error"); + } + }, [showAlert, loadDriverInfo]); + + /** + * 새 차량 등록 모달 열기 + */ + const openVehicleRegisterModal = useCallback(() => { + setNewVehicleData({ + vehicleNumber: "", + vehicleType: "", + branchName: driverFormData.branchName || "", // 기존 소속 지점 유지 + }); + setIsVehicleRegisterModalOpen(true); + }, [driverFormData.branchName]); + + /** + * 새 차량 등록 모달 닫기 + */ + const closeVehicleRegisterModal = useCallback(() => { + setIsVehicleRegisterModalOpen(false); + }, []); + + /** + * 새 차량 데이터 변경 + */ + const updateNewVehicleData = useCallback((field: keyof VehicleRegisterData, value: string) => { + setNewVehicleData((prev) => ({ + ...prev, + [field]: value, + })); + }, []); + + /** + * 새 차량 등록 처리 + */ + const handleRegisterVehicle = useCallback(async () => { + if (!newVehicleData.vehicleNumber) { + showAlert("입력 오류", "차량번호는 필수입니다.", "error"); + return; + } + + try { + const response = await registerDriverVehicle(newVehicleData); + if (response.success) { + showAlert("등록 완료", "차량이 등록되었습니다.", "success"); + setIsVehicleRegisterModalOpen(false); + // 운전자 정보 새로고침 + await loadDriverInfo(); + } else { + showAlert("등록 실패", response.message || "차량 등록에 실패했습니다.", "error"); + } + } catch (error) { + console.error("차량 등록 실패:", error); + showAlert("오류", "차량 등록 중 오류가 발생했습니다.", "error"); + } + }, [newVehicleData, showAlert, loadDriverInfo]); + /** * 이미지 선택 처리 */ @@ -341,6 +437,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr licenseNumber: driverFormData.licenseNumber, vehicleNumber: driverFormData.vehicleNumber, vehicleType: driverFormData.vehicleType, + branchName: driverFormData.branchName, }); if (!driverResponse.success) { @@ -400,9 +497,14 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr // 운전자 관련 상태 isDriver, + hasVehicle, driverInfo, driverFormData, + // 새 차량 등록 모달 상태 + isVehicleRegisterModalOpen, + newVehicleData, + // 액션 openProfileModal, closeProfileModal, @@ -415,5 +517,10 @@ export const useProfile = (user: any, refreshUserData: () => Promise, refr updateDriverFormData, handleDriverStatusChange, handleDriverAccountDelete, + handleDeleteVehicle, + openVehicleRegisterModal, + closeVehicleRegisterModal, + updateNewVehicleData, + handleRegisterVehicle, }; }; diff --git a/frontend/lib/api/driver.ts b/frontend/lib/api/driver.ts index 8600c5cb..8074660a 100644 --- a/frontend/lib/api/driver.ts +++ b/frontend/lib/api/driver.ts @@ -9,6 +9,7 @@ export interface DriverProfile { vehicleNumber: string; vehicleType: string | null; vehicleStatus: string | null; + branchName: string | null; } export interface DriverProfileUpdateData { @@ -17,6 +18,7 @@ export interface DriverProfileUpdateData { licenseNumber?: string; vehicleNumber?: string; vehicleType?: string; + branchName?: string; } /** @@ -72,6 +74,47 @@ export async function updateDriverStatus( } } +/** + * 차량 삭제 (기록 보존) + */ +export async function deleteDriverVehicle(): Promise<{ + success: boolean; + message?: string; +}> { + try { + const response = await apiClient.delete("/driver/vehicle"); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "차량 삭제에 실패했습니다.", + }; + } +} + +/** + * 새 차량 등록 + */ +export interface VehicleRegisterData { + vehicleNumber: string; + vehicleType?: string; + branchName?: string; +} + +export async function registerDriverVehicle( + data: VehicleRegisterData +): Promise<{ success: boolean; message?: string }> { + try { + const response = await apiClient.post("/driver/vehicle", data); + return response.data; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.message || "차량 등록에 실패했습니다.", + }; + } +} + /** * 회원 탈퇴 */ From 436d604bb3219b708bf8d647132e126302e849c9 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 2 Dec 2025 11:12:09 +0900 Subject: [PATCH 05/14] =?UTF-8?q?REST=20API=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=ED=9A=8C=EC=82=AC=EB=B3=84=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EB=AA=85=20=EC=A4=91=EB=B3=B5=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/externalRestApiConnectionRoutes.ts | 2 ++ backend-node/src/services/externalRestApiConnectionService.ts | 1 + frontend/components/admin/RestApiConnectionModal.tsx | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts index 48813575..14fd17d0 100644 --- a/backend-node/src/routes/externalRestApiConnectionRoutes.ts +++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts @@ -97,6 +97,8 @@ router.post( const data: ExternalRestApiConnection = { ...req.body, created_by: req.user?.userId || "system", + // 로그인 사용자의 company_code 사용 (프론트에서 안 보내도 자동 설정) + company_code: req.body.company_code || req.user?.companyCode || "*", }; const result = diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index af37eff1..cc1a46e5 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -233,6 +233,7 @@ export class ExternalRestApiConnectionService { // 디버깅: 저장하려는 데이터 로깅 logger.info(`REST API 연결 생성 요청 데이터:`, { connection_name: data.connection_name, + company_code: data.company_code, default_method: data.default_method, endpoint_path: data.endpoint_path, base_url: data.base_url, diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index aa7d79d8..3de34800 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -232,7 +232,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: timeout, retry_count: retryCount, retry_delay: retryDelay, - company_code: "*", + // company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정 is_active: isActive ? "Y" : "N", }; From 9078873240be75c8410bd6b6c77b2dfc736e8524 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 2 Dec 2025 14:24:43 +0900 Subject: [PATCH 06/14] =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20restapi=EB=8F=84=20=EC=97=B0=EA=B2=B0=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EA=B3=A0=EC=97=AC=EB=9F=AC=EA=B0=9C=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=8B=9C=EC=BC=9C=EB=86=93=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/flowController.ts | 10 +- .../externalRestApiConnectionService.ts | 146 ++++++ .../src/services/flowDefinitionService.ts | 25 +- backend-node/src/types/flow.ts | 30 +- .../admin/flow-management/[id]/page.tsx | 4 + .../app/(main)/admin/flow-management/page.tsx | 418 +++++++++++++++++- .../components/flow/FlowConditionBuilder.tsx | 125 +++++- frontend/components/flow/FlowStepPanel.tsx | 227 +++++++++- frontend/components/screen/ScreenDesigner.tsx | 11 +- frontend/types/flow.ts | 41 +- frontend/types/flowExternalDb.ts | 2 +- 11 files changed, 989 insertions(+), 50 deletions(-) diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index e03bfe25..9459e1f6 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -66,11 +66,12 @@ export class FlowController { return; } - // REST API인 경우 테이블 존재 확인 스킵 - const isRestApi = dbSourceType === "restapi"; + // REST API 또는 다중 연결인 경우 테이블 존재 확인 스킵 + const isRestApi = dbSourceType === "restapi" || dbSourceType === "multi_restapi"; + const isMultiConnection = dbSourceType === "multi_restapi" || dbSourceType === "multi_external_db"; - // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 제외) - if (tableName && !isRestApi && !tableName.startsWith("_restapi_")) { + // 테이블 이름이 제공된 경우에만 존재 확인 (REST API 및 다중 연결 제외) + if (tableName && !isRestApi && !isMultiConnection && !tableName.startsWith("_restapi_") && !tableName.startsWith("_multi_restapi_") && !tableName.startsWith("_multi_external_db_")) { const tableExists = await this.flowDefinitionService.checkTableExists(tableName); if (!tableExists) { @@ -92,6 +93,7 @@ export class FlowController { restApiConnectionId, restApiEndpoint, restApiJsonPath, + restApiConnections: req.body.restApiConnections, // 다중 REST API 설정 }, userId, userCompanyCode diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index af37eff1..89096fbb 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -1091,4 +1091,150 @@ export class ExternalRestApiConnectionService { throw new Error("올바르지 않은 인증 타입입니다."); } } + + /** + * 다중 REST API 데이터 조회 및 병합 + * 여러 REST API의 응답을 병합하여 하나의 데이터셋으로 반환 + */ + static async fetchMultipleData( + configs: Array<{ + connectionId: number; + endpoint: string; + jsonPath: string; + alias: string; + }>, + userCompanyCode?: string + ): Promise; + total: number; + sources: Array<{ connectionId: number; connectionName: string; rowCount: number }>; + }>> { + try { + logger.info(`다중 REST API 데이터 조회 시작: ${configs.length}개 API`); + + // 각 API에서 데이터 조회 + const results = await Promise.all( + configs.map(async (config) => { + try { + const result = await this.fetchData( + config.connectionId, + config.endpoint, + config.jsonPath, + userCompanyCode + ); + + if (result.success && result.data) { + return { + success: true, + connectionId: config.connectionId, + connectionName: result.data.connectionInfo.connectionName, + alias: config.alias, + rows: result.data.rows, + columns: result.data.columns, + }; + } else { + logger.warn(`API ${config.connectionId} 조회 실패:`, result.message); + return { + success: false, + connectionId: config.connectionId, + connectionName: "", + alias: config.alias, + rows: [], + columns: [], + error: result.message, + }; + } + } catch (error) { + logger.error(`API ${config.connectionId} 조회 오류:`, error); + return { + success: false, + connectionId: config.connectionId, + connectionName: "", + alias: config.alias, + rows: [], + columns: [], + error: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } + }) + ); + + // 성공한 결과만 필터링 + const successfulResults = results.filter(r => r.success); + + if (successfulResults.length === 0) { + return { + success: false, + message: "모든 REST API 조회에 실패했습니다.", + error: { + code: "ALL_APIS_FAILED", + details: results.map(r => ({ connectionId: r.connectionId, error: r.error })), + }, + }; + } + + // 컬럼 병합 (별칭 적용) + const mergedColumns: Array<{ columnName: string; columnLabel: string; dataType: string; sourceApi: string }> = []; + + for (const result of successfulResults) { + for (const col of result.columns) { + const prefixedColumnName = result.alias ? `${result.alias}${col.columnName}` : col.columnName; + mergedColumns.push({ + columnName: prefixedColumnName, + columnLabel: `${col.columnLabel} (${result.connectionName})`, + dataType: col.dataType, + sourceApi: result.connectionName, + }); + } + } + + // 데이터 병합 (가로 병합: 각 API의 첫 번째 행끼리 병합) + // 참고: 실제 사용 시에는 조인 키가 필요할 수 있음 + const maxRows = Math.max(...successfulResults.map(r => r.rows.length)); + const mergedRows: any[] = []; + + for (let i = 0; i < maxRows; i++) { + const mergedRow: any = {}; + + for (const result of successfulResults) { + const row = result.rows[i] || {}; + + for (const [key, value] of Object.entries(row)) { + const prefixedKey = result.alias ? `${result.alias}${key}` : key; + mergedRow[prefixedKey] = value; + } + } + + mergedRows.push(mergedRow); + } + + logger.info(`다중 REST API 데이터 병합 완료: ${mergedRows.length}개 행, ${mergedColumns.length}개 컬럼`); + + return { + success: true, + data: { + rows: mergedRows, + columns: mergedColumns, + total: mergedRows.length, + sources: successfulResults.map(r => ({ + connectionId: r.connectionId, + connectionName: r.connectionName, + rowCount: r.rows.length, + })), + }, + message: `${successfulResults.length}개 API에서 총 ${mergedRows.length}개 데이터를 조회했습니다.`, + }; + } catch (error) { + logger.error("다중 REST API 데이터 조회 오류:", error); + return { + success: false, + message: "다중 REST API 데이터 조회에 실패했습니다.", + error: { + code: "MULTI_FETCH_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } } diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 4416faa0..80c920ad 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -30,6 +30,7 @@ export class FlowDefinitionService { restApiConnectionId: request.restApiConnectionId, restApiEndpoint: request.restApiEndpoint, restApiJsonPath: request.restApiJsonPath, + restApiConnections: request.restApiConnections, companyCode, userId, }); @@ -38,9 +39,9 @@ export class FlowDefinitionService { 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 + rest_api_connections, company_code, created_by ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; @@ -52,7 +53,8 @@ export class FlowDefinitionService { request.dbConnectionId || null, request.restApiConnectionId || null, request.restApiEndpoint || null, - request.restApiJsonPath || "data", + request.restApiJsonPath || "response", + request.restApiConnections ? JSON.stringify(request.restApiConnections) : null, companyCode, userId, ]; @@ -209,6 +211,19 @@ export class FlowDefinitionService { * DB 행을 FlowDefinition 객체로 변환 */ private mapToFlowDefinition(row: any): FlowDefinition { + // rest_api_connections 파싱 (JSONB → 배열) + let restApiConnections = undefined; + if (row.rest_api_connections) { + try { + restApiConnections = typeof row.rest_api_connections === 'string' + ? JSON.parse(row.rest_api_connections) + : row.rest_api_connections; + } catch (e) { + console.warn("Failed to parse rest_api_connections:", e); + restApiConnections = []; + } + } + return { id: row.id, name: row.name, @@ -216,10 +231,12 @@ export class FlowDefinitionService { tableName: row.table_name, dbSourceType: row.db_source_type || "internal", dbConnectionId: row.db_connection_id, - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId: row.rest_api_connection_id, restApiEndpoint: row.rest_api_endpoint, restApiJsonPath: row.rest_api_json_path, + // 다중 REST API 관련 필드 + restApiConnections: restApiConnections, companyCode: row.company_code || "*", isActive: row.is_active, createdBy: row.created_by, diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index c877a2b3..9f105a49 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -2,18 +2,38 @@ * 플로우 관리 시스템 타입 정의 */ +// 다중 REST API 연결 설정 +export interface RestApiConnectionConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") +} + +// 다중 외부 DB 연결 설정 +export interface ExternalDbConnectionConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") +} + // 플로우 정의 export interface FlowDefinition { id: number; name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우) - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId?: number; // REST API 연결 ID (restapi인 경우) restApiEndpoint?: string; // REST API 엔드포인트 restApiJsonPath?: string; // JSON 응답에서 데이터 경로 (기본: data) + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode: string; // 회사 코드 (* = 공통) isActive: boolean; createdBy?: string; @@ -26,12 +46,14 @@ export interface CreateFlowDefinitionRequest { name: string; description?: string; tableName: string; - dbSourceType?: "internal" | "external" | "restapi"; // 데이터 소스 타입 + dbSourceType?: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db"; // 데이터 소스 타입 dbConnectionId?: number; // 외부 DB 연결 ID - // REST API 관련 필드 + // REST API 관련 필드 (단일) restApiConnectionId?: number; // REST API 연결 ID restApiEndpoint?: string; // REST API 엔드포인트 restApiJsonPath?: string; // JSON 응답에서 데이터 경로 + // 다중 REST API 관련 필드 + restApiConnections?: RestApiConnectionConfig[]; // 다중 REST API 설정 배열 companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용) } diff --git a/frontend/app/(main)/admin/flow-management/[id]/page.tsx b/frontend/app/(main)/admin/flow-management/[id]/page.tsx index a311bc63..b8d14e19 100644 --- a/frontend/app/(main)/admin/flow-management/[id]/page.tsx +++ b/frontend/app/(main)/admin/flow-management/[id]/page.tsx @@ -319,6 +319,10 @@ export default function FlowEditorPage() { flowTableName={flowDefinition?.tableName} // 플로우 정의의 테이블명 전달 flowDbSourceType={flowDefinition?.dbSourceType} // DB 소스 타입 전달 flowDbConnectionId={flowDefinition?.dbConnectionId} // 외부 DB 연결 ID 전달 + flowRestApiConnectionId={flowDefinition?.restApiConnectionId} // REST API 연결 ID 전달 + flowRestApiEndpoint={flowDefinition?.restApiEndpoint} // REST API 엔드포인트 전달 + flowRestApiJsonPath={flowDefinition?.restApiJsonPath} // REST API JSON 경로 전달 + flowRestApiConnections={flowDefinition?.restApiConnections} // 다중 REST API 설정 전달 onClose={() => setSelectedStep(null)} onUpdate={loadFlowData} /> diff --git a/frontend/app/(main)/admin/flow-management/page.tsx b/frontend/app/(main)/admin/flow-management/page.tsx index 5a335daf..d283f72d 100644 --- a/frontend/app/(main)/admin/flow-management/page.tsx +++ b/frontend/app/(main)/admin/flow-management/page.tsx @@ -64,7 +64,30 @@ export default function FlowManagementPage() { // REST API 연결 관련 상태 const [restApiConnections, setRestApiConnections] = useState([]); const [restApiEndpoint, setRestApiEndpoint] = useState(""); - const [restApiJsonPath, setRestApiJsonPath] = useState("data"); + const [restApiJsonPath, setRestApiJsonPath] = useState("response"); + + // 다중 REST API 선택 상태 + interface RestApiConfig { + connectionId: number; + connectionName: string; + endpoint: string; + jsonPath: string; + alias: string; // 컬럼 접두어 (예: "api1_") + } + const [selectedRestApis, setSelectedRestApis] = useState([]); + const [isMultiRestApi, setIsMultiRestApi] = useState(false); // 다중 REST API 모드 + + // 다중 외부 DB 선택 상태 + interface ExternalDbConfig { + connectionId: number; + connectionName: string; + dbType: string; + tableName: string; + alias: string; // 컬럼 접두어 (예: "db1_") + } + const [selectedExternalDbs, setSelectedExternalDbs] = useState([]); + const [isMultiExternalDb, setIsMultiExternalDb] = useState(false); // 다중 외부 DB 모드 + const [multiDbTableLists, setMultiDbTableLists] = useState>({}); // 각 DB별 테이블 목록 // 생성 폼 상태 const [formData, setFormData] = useState({ @@ -207,25 +230,161 @@ export default function FlowManagementPage() { } }, [selectedDbSource]); + // 다중 외부 DB 추가 + const addExternalDbConfig = async (connectionId: number) => { + const connection = externalConnections.find(c => c.id === connectionId); + if (!connection) return; + + // 이미 추가된 경우 스킵 + if (selectedExternalDbs.some(db => db.connectionId === connectionId)) { + toast({ + title: "이미 추가됨", + description: "해당 외부 DB가 이미 추가되어 있습니다.", + variant: "destructive", + }); + return; + } + + // 해당 DB의 테이블 목록 로드 + try { + const data = await ExternalDbConnectionAPI.getTables(connectionId); + 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); + setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames })); + } + } catch (error) { + console.error("외부 DB 테이블 목록 조회 오류:", error); + } + + const newConfig: ExternalDbConfig = { + connectionId, + connectionName: connection.connection_name, + dbType: connection.db_type, + tableName: "", + alias: `db${selectedExternalDbs.length + 1}_`, // 자동 별칭 생성 + }; + + setSelectedExternalDbs([...selectedExternalDbs, newConfig]); + }; + + // 다중 외부 DB 삭제 + const removeExternalDbConfig = (connectionId: number) => { + setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId)); + }; + + // 다중 외부 DB 설정 업데이트 + const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => { + setSelectedExternalDbs(selectedExternalDbs.map(db => + db.connectionId === connectionId ? { ...db, [field]: value } : db + )); + }; + + // 다중 REST API 추가 + const addRestApiConfig = (connectionId: number) => { + const connection = restApiConnections.find(c => c.id === connectionId); + if (!connection) return; + + // 이미 추가된 경우 스킵 + if (selectedRestApis.some(api => api.connectionId === connectionId)) { + toast({ + title: "이미 추가됨", + description: "해당 REST API가 이미 추가되어 있습니다.", + variant: "destructive", + }); + return; + } + + // 연결 테이블의 기본값 사용 + const newConfig: RestApiConfig = { + connectionId, + connectionName: connection.connection_name, + endpoint: connection.endpoint_path || "", // 연결 테이블의 기본 엔드포인트 + jsonPath: "response", // 기본값 + alias: `api${selectedRestApis.length + 1}_`, // 자동 별칭 생성 + }; + + setSelectedRestApis([...selectedRestApis, newConfig]); + }; + + // 다중 REST API 삭제 + const removeRestApiConfig = (connectionId: number) => { + setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId)); + }; + + // 다중 REST API 설정 업데이트 + const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => { + setSelectedRestApis(selectedRestApis.map(api => + api.connectionId === connectionId ? { ...api, [field]: value } : api + )); + }; + // 플로우 생성 const handleCreate = async () => { console.log("🚀 handleCreate called with formData:", formData); - // REST API인 경우 테이블 이름 검증 스킵 - const isRestApi = selectedDbSource.startsWith("restapi_"); + // REST API 또는 다중 선택인 경우 테이블 이름 검증 스킵 + const isRestApi = selectedDbSource.startsWith("restapi_") || isMultiRestApi; + const isMultiMode = isMultiRestApi || isMultiExternalDb; - if (!formData.name || (!isRestApi && !formData.tableName)) { - console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi }); + if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) { + console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode }); toast({ title: "입력 오류", - description: isRestApi ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", + description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", variant: "destructive", }); return; } - // REST API인 경우 엔드포인트 검증 - if (isRestApi && !restApiEndpoint) { + // 다중 REST API 모드인 경우 검증 + if (isMultiRestApi) { + if (selectedRestApis.length === 0) { + toast({ + title: "입력 오류", + description: "최소 하나의 REST API를 추가해주세요.", + variant: "destructive", + }); + return; + } + + // 각 API의 엔드포인트 검증 + const missingEndpoint = selectedRestApis.find(api => !api.endpoint); + if (missingEndpoint) { + toast({ + title: "입력 오류", + description: `${missingEndpoint.connectionName}의 엔드포인트를 입력해주세요.`, + variant: "destructive", + }); + return; + } + } else if (isMultiExternalDb) { + // 다중 외부 DB 모드인 경우 검증 + if (selectedExternalDbs.length === 0) { + toast({ + title: "입력 오류", + description: "최소 하나의 외부 DB를 추가해주세요.", + variant: "destructive", + }); + return; + } + + // 각 DB의 테이블 선택 검증 + const missingTable = selectedExternalDbs.find(db => !db.tableName); + if (missingTable) { + toast({ + title: "입력 오류", + description: `${missingTable.connectionName}의 테이블을 선택해주세요.`, + variant: "destructive", + }); + return; + } + } else if (isRestApi && !restApiEndpoint) { + // 단일 REST API인 경우 엔드포인트 검증 toast({ title: "입력 오류", description: "REST API 엔드포인트는 필수입니다.", @@ -236,11 +395,15 @@ export default function FlowManagementPage() { try { // 데이터 소스 타입 및 ID 파싱 - let dbSourceType: "internal" | "external" | "restapi" = "internal"; + let dbSourceType: "internal" | "external" | "restapi" | "multi_restapi" | "multi_external_db" = "internal"; let dbConnectionId: number | undefined = undefined; let restApiConnectionId: number | undefined = undefined; - if (selectedDbSource === "internal") { + if (isMultiRestApi) { + dbSourceType = "multi_restapi"; + } else if (isMultiExternalDb) { + dbSourceType = "multi_external_db"; + } else if (selectedDbSource === "internal") { dbSourceType = "internal"; } else if (selectedDbSource.startsWith("external_db_")) { dbSourceType = "external"; @@ -257,11 +420,27 @@ export default function FlowManagementPage() { dbConnectionId, }; - // REST API인 경우 추가 정보 - if (dbSourceType === "restapi") { + // 다중 REST API인 경우 + if (dbSourceType === "multi_restapi") { + requestData.restApiConnections = selectedRestApis; + // 다중 REST API는 첫 번째 API의 ID를 기본으로 사용 + requestData.restApiConnectionId = selectedRestApis[0]?.connectionId; + requestData.restApiEndpoint = selectedRestApis[0]?.endpoint; + requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response"; + // 가상 테이블명: 모든 연결 ID를 조합 + requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`; + } else if (dbSourceType === "multi_external_db") { + // 다중 외부 DB인 경우 + requestData.externalDbConnections = selectedExternalDbs; + // 첫 번째 DB의 ID를 기본으로 사용 + requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId; + // 가상 테이블명: 모든 연결 ID와 테이블명 조합 + requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`; + } else if (dbSourceType === "restapi") { + // 단일 REST API인 경우 requestData.restApiConnectionId = restApiConnectionId; requestData.restApiEndpoint = restApiEndpoint; - requestData.restApiJsonPath = restApiJsonPath || "data"; + requestData.restApiJsonPath = restApiJsonPath || "response"; // REST API는 가상 테이블명 사용 requestData.tableName = `_restapi_${restApiConnectionId}`; } @@ -277,7 +456,11 @@ export default function FlowManagementPage() { setFormData({ name: "", description: "", tableName: "" }); setSelectedDbSource("internal"); setRestApiEndpoint(""); - setRestApiJsonPath("data"); + setRestApiJsonPath("response"); + setSelectedRestApis([]); + setSelectedExternalDbs([]); + setIsMultiRestApi(false); + setIsMultiExternalDb(false); loadFlows(); } else { toast({ @@ -485,13 +668,27 @@ export default function FlowManagementPage() {

@@ -535,8 +751,160 @@ export default function FlowManagementPage() {

- {/* REST API인 경우 엔드포인트 설정 */} - {selectedDbSource.startsWith("restapi_") ? ( + {/* 다중 REST API 선택 UI */} + {isMultiRestApi && ( +
+
+ + +
+ + {selectedRestApis.length === 0 ? ( +
+

+ 위에서 REST API를 추가해주세요 +

+
+ ) : ( +
+ {selectedRestApis.map((api) => ( +
+
+ {api.connectionName} + + ({api.endpoint || "기본 엔드포인트"}) + +
+ +
+ ))} +
+ )} +

+ 선택한 REST API들의 데이터가 자동으로 병합됩니다. +

+
+ )} + + {/* 다중 외부 DB 선택 UI */} + {isMultiExternalDb && ( +
+
+ + +
+ + {selectedExternalDbs.length === 0 ? ( +
+

+ 위에서 외부 DB를 추가해주세요 +

+
+ ) : ( +
+ {selectedExternalDbs.map((db) => ( +
+
+ + {db.connectionName} ({db.dbType?.toUpperCase()}) + + +
+
+
+ + +
+
+ + updateExternalDbConfig(db.connectionId, "alias", e.target.value)} + placeholder="db1_" + className="h-7 text-xs" + /> +
+
+
+ ))} +
+ )} +

+ 선택한 외부 DB들의 데이터가 자동으로 병합됩니다. 각 DB별 테이블을 선택해주세요. +

+
+ )} + + {/* 단일 REST API인 경우 엔드포인트 설정 */} + {!isMultiRestApi && selectedDbSource.startsWith("restapi_") && ( <>
- ) : ( - /* 테이블 선택 (내부 DB 또는 외부 DB) */ + )} + + {/* 테이블 선택 (내부 DB 또는 단일 외부 DB - 다중 선택 모드가 아닌 경우만) */} + {!isMultiRestApi && !isMultiExternalDb && !selectedDbSource.startsWith("restapi_") && (
)} + + {/* REST API 연동 설정 */} + {formData.integrationType === "rest_api" && ( +
+
+ + +
+ + {formData.integrationConfig?.connectionId && ( + <> +
+ + +
+ +
+ + + setFormData({ + ...formData, + integrationConfig: { + ...formData.integrationConfig!, + endpoint: e.target.value, + } as any, + }) + } + placeholder="/api/update" + /> +

+ 데이터 이동 시 호출할 API 엔드포인트 +

+
+ +
+ +