From a9135165d9941a4390c690822d2b2e643a3d507b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 12 Dec 2025 10:55:09 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20UniversalFormModal=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88=20=EA=B7=9C=EC=B9=99=20=EC=9E=90=EB=8F=99=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모달 재오픈 시 동일 번호 유지 (previewCode 사용) - 저장 시 정상적인 순번 증가 (allocateCode에서 nextSequence 사용) - refreshKey를 React key로 전달하여 컴포넌트 강제 리마운트 - ruleId를 부모 컴포넌트까지 전달하여 buttonActions에서 감지 - 미리보기와 저장 번호 일치 (currentSequence + 1 통일) --- .../src/services/numberingRuleService.ts | 5 ++- .../lib/registry/DynamicComponentRenderer.tsx | 3 +- .../UniversalFormModalComponent.tsx | 42 +++++++++++++------ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 4a9b53a4..8208ecc5 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -959,9 +959,10 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { - // 순번 (자동 증가 숫자) + // 순번 (자동 증가 숫자 - 다음 번호 사용) const length = autoConfig.sequenceLength || 3; - return String(rule.currentSequence || 1).padStart(length, "0"); + const nextSequence = (rule.currentSequence || 0) + 1; + return String(nextSequence).padStart(length, "0"); } case "number": { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 4d12309b..dc92c38a 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -468,7 +468,8 @@ export const DynamicComponentRenderer: React.FC = return rendererInstance.render(); } else { // 함수형 컴포넌트 - return ; + // refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제 + return ; } } } catch (error) { diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index a78d2e95..14f84858 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -23,7 +23,7 @@ import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; -import { generateNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; +import { generateNumberingCode, allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; @@ -257,8 +257,11 @@ export function UniversalFormModalComponent({ // 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀 // (UniversalFormModal이 해당 필드의 주인이므로) for (const [key, value] of Object.entries(formData)) { - // 설정에 정의된 필드만 병합 - if (configuredFields.has(key)) { + // 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합 + const isConfiguredField = configuredFields.has(key); + const isNumberingRuleId = key.endsWith("_numberingRuleId"); + + if (isConfiguredField || isNumberingRuleId) { if (value !== undefined && value !== null && value !== "") { event.detail.formData[key] = value; console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value); @@ -401,7 +404,7 @@ export function UniversalFormModalComponent({ } isGeneratingRef.current = true; // 진행 중 표시 - console.log('[채번] 생성 시작'); + console.log('[채번] 미리보기 생성 시작'); const updatedData = { ...currentFormData }; let hasChanges = false; @@ -417,17 +420,32 @@ export function UniversalFormModalComponent({ !updatedData[field.columnName] ) { try { - console.log(`[채번 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`); - // generateOnOpen: 모달 열 때 실제 순번 할당 (DB 시퀀스 즉시 증가) - const response = await allocateNumberingCode(field.numberingRule.ruleId); + console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`); + // generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함) + const response = await previewNumberingCode(field.numberingRule.ruleId); if (response.success && response.data?.generatedCode) { updatedData[field.columnName] = response.data.generatedCode; + + // 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식) + const ruleIdKey = `${field.columnName}_numberingRuleId`; + updatedData[ruleIdKey] = field.numberingRule.ruleId; + hasChanges = true; numberingGeneratedRef.current = true; // 생성 완료 표시 - console.log(`[채번 완료] ${field.columnName} = ${response.data.generatedCode}`); + console.log(`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`); + console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`); + + // 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal) + if (onChange) { + onChange({ + ...updatedData, + [ruleIdKey]: field.numberingRule.ruleId + }); + console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`); + } } } catch (error) { - console.error(`채번규칙 생성 실패 (${field.columnName}):`, error); + console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error); } } } @@ -439,7 +457,7 @@ export function UniversalFormModalComponent({ setFormData(updatedData); } }, - [config], + [config, onChange], ); // 필드 값 변경 핸들러 @@ -659,9 +677,9 @@ export function UniversalFormModalComponent({ const saveSingleRow = useCallback(async () => { const dataToSave = { ...formData }; - // 메타데이터 필드 제거 + // 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용) Object.keys(dataToSave).forEach((key) => { - if (key.startsWith("_")) { + if (key.startsWith("_") && !key.includes("_numberingRuleId")) { delete dataToSave[key]; } }); From 1680163c612c6a4e13375ec61b4d19be49c8e834 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 12 Dec 2025 11:10:51 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20ModalRepeaterTable=20=EB=B9=88=20?= =?UTF-8?q?=ED=96=89=20=EC=9E=90=EB=8F=99=20=ED=91=9C=EC=8B=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 등록 모달 오픈 시 빈 객체 필터링 기능 추가 - isEmptyRow 함수로 안전한 빈 객체 판단 (id 필드 체크) - useState 초기화 및 useEffect 동기화에 필터링 적용 - 수정 모달의 실제 데이터는 id 필드로 보호 --- .../ModalRepeaterTableComponent.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 92eb4bb7..64c9e95f 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -198,14 +198,43 @@ export function ModalRepeaterTableComponent({ const columnName = component?.columnName; const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || []; + // 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지) + const isEmptyRow = (item: any): boolean => { + if (!item || typeof item !== 'object') return true; + + // id가 있으면 실제 데이터 (수정 모달) + if (item.id) return false; + + // 모든 값이 비어있는지 확인 (계산 필드 제외) + const hasValue = Object.entries(item).some(([key, value]) => { + // 계산 필드나 메타데이터는 제외 + if (key.startsWith('_') || key === 'total_amount') return false; + + // 실제 값이 있는지 확인 + return value !== undefined && + value !== null && + value !== '' && + value !== 0 && + value !== '0' && + value !== '0.00'; + }); + + return !hasValue; + }; + // 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해) - const [localValue, setLocalValue] = useState(externalValue); + const [localValue, setLocalValue] = useState(() => { + return externalValue.filter((item) => !isEmptyRow(item)); + }); // 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화 useEffect(() => { + // 빈 객체 필터링 + const filteredValue = externalValue.filter((item) => !isEmptyRow(item)); + // 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화 - if (JSON.stringify(externalValue) !== JSON.stringify(localValue)) { - setLocalValue(externalValue); + if (JSON.stringify(filteredValue) !== JSON.stringify(localValue)) { + setLocalValue(filteredValue); } }, [externalValue]); From c85841b59fe65ed00ca01775c20fd71ccd9531a1 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 12 Dec 2025 13:46:20 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(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=20?= =?UTF-8?q?=EC=8B=9C=20ID=20=ED=83=80=EC=9E=85=20=EB=B3=80=ED=99=98=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 - 조인 키가 '_id' 또는 'id'인 경우 문자열을 숫자로 변환 - 백엔드 ILIKE 검색 방지로 정확한 ID 매칭 보장 - API 호출 파라미터 로깅 추가 (디버깅용) --- .../RepeatScreenModalComponent.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 980bbfe9..d5490467 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -464,8 +464,17 @@ export function RepeatScreenModalComponent({ // 조인 조건 생성 const filters: Record = {}; for (const condition of dataSourceConfig.joinConditions) { - const refValue = representativeData[condition.referenceKey]; + let refValue = representativeData[condition.referenceKey]; if (refValue !== undefined && refValue !== null) { + // 숫자형 ID인 경우 숫자로 변환 (문자열 '189' → 숫자 189) + // 백엔드에서 entity 타입 컬럼 검색 시 문자열이면 ILIKE 검색을 수행하므로 + // 정확한 ID 매칭을 위해 숫자로 변환해야 함 + if (condition.sourceKey.endsWith('_id') || condition.sourceKey === 'id') { + const numValue = Number(refValue); + if (!isNaN(numValue)) { + refValue = numValue; + } + } filters[condition.sourceKey] = refValue; } } @@ -475,6 +484,14 @@ export function RepeatScreenModalComponent({ continue; } + console.log(`[RepeatScreenModal] 외부 테이블 API 호출:`, { + sourceTable: dataSourceConfig.sourceTable, + filters, + joinConditions: dataSourceConfig.joinConditions, + representativeDataId: representativeData.id, + representativeDataIdType: typeof representativeData.id, + }); + // API 호출 - 메인 테이블 데이터 const response = await apiClient.post( `/table-management/tables/${dataSourceConfig.sourceTable}/data`, From 11215e3316a1d4decfa4455bdffc3eb39611823b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 12 Dec 2025 14:02:17 +0900 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=88=98=EC=A3=BC=20=EB=93=B1=EB=A1=9D=20=EB=AA=A8=EB=93=88(or?= =?UTF-8?q?derController)=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드: orderController.ts, orderRoutes.ts 삭제 - 프론트엔드: components/order/, order-registration-modal/ 삭제 - app.ts, index.ts, getComponentConfigPanel.tsx에서 참조 제거 - 현재 sales_order_mng 기반 수주 시스템 사용으로 구 모듈 불필요 --- backend-node/src/app.ts | 2 - .../src/controllers/orderController.ts | 276 --------- backend-node/src/routes/orderRoutes.ts | 20 - .../components/order/OrderCustomerSearch.tsx | 49 -- .../order/OrderItemRepeaterTable.tsx | 135 ----- .../order/OrderRegistrationModal.tsx | 572 ------------------ frontend/components/order/README.md | 374 ------------ frontend/components/order/orderConstants.ts | 21 - frontend/lib/registry/components/index.ts | 1 - .../OrderRegistrationModalConfigPanel.tsx | 93 --- .../OrderRegistrationModalRenderer.tsx | 56 -- .../order-registration-modal/index.ts | 68 --- .../lib/utils/getComponentConfigPanel.tsx | 3 - 13 files changed, 1670 deletions(-) delete mode 100644 backend-node/src/controllers/orderController.ts delete mode 100644 backend-node/src/routes/orderRoutes.ts delete mode 100644 frontend/components/order/OrderCustomerSearch.tsx delete mode 100644 frontend/components/order/OrderItemRepeaterTable.tsx delete mode 100644 frontend/components/order/OrderRegistrationModal.tsx delete mode 100644 frontend/components/order/README.md delete mode 100644 frontend/components/order/orderConstants.ts delete mode 100644 frontend/lib/registry/components/order-registration-modal/OrderRegistrationModalConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/order-registration-modal/OrderRegistrationModalRenderer.tsx delete mode 100644 frontend/lib/registry/components/order-registration-modal/index.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 5c2415ea..652677ca 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -71,7 +71,6 @@ import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카 import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 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 driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 @@ -249,7 +248,6 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 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/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts deleted file mode 100644 index 82043964..00000000 --- a/backend-node/src/controllers/orderController.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; -import { getPool } from "../database/db"; -import { logger } from "../utils/logger"; - -/** - * 수주 번호 생성 함수 - * 형식: ORD + YYMMDD + 4자리 시퀀스 - * 예: ORD250114001 - */ -async function generateOrderNumber(companyCode: string): Promise { - const pool = getPool(); - const today = new Date(); - const year = today.getFullYear().toString().slice(2); // 25 - const month = String(today.getMonth() + 1).padStart(2, "0"); // 01 - const day = String(today.getDate()).padStart(2, "0"); // 14 - const dateStr = `${year}${month}${day}`; // 250114 - - // 당일 수주 카운트 조회 - const countQuery = ` - SELECT COUNT(*) as count - FROM order_mng_master - WHERE objid LIKE $1 - AND writer LIKE $2 - `; - - const pattern = `ORD${dateStr}%`; - const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]); - const count = parseInt(result.rows[0]?.count || "0"); - const seq = count + 1; - - return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001 -} - -/** - * 수주 등록 API - * POST /api/orders - */ -export async function createOrder(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - - try { - const { - inputMode, // 입력 방식 - customerCode, // 거래처 코드 - deliveryDate, // 납품일 - items, // 품목 목록 - memo, // 메모 - } = req.body; - - // 멀티테넌시 - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - - // 유효성 검사 - if (!customerCode) { - return res.status(400).json({ - success: false, - message: "거래처 코드는 필수입니다", - }); - } - - if (!items || items.length === 0) { - return res.status(400).json({ - success: false, - message: "품목은 최소 1개 이상 필요합니다", - }); - } - - // 수주 번호 생성 - const orderNo = await generateOrderNumber(companyCode); - - // 전체 금액 계산 - const totalAmount = items.reduce( - (sum: number, item: any) => sum + (item.amount || 0), - 0 - ); - - // 수주 마스터 생성 - const masterQuery = ` - INSERT INTO order_mng_master ( - objid, - partner_objid, - final_delivery_date, - reason, - status, - reg_date, - writer - ) VALUES ($1, $2, $3, $4, $5, NOW(), $6) - RETURNING * - `; - - const masterResult = await pool.query(masterQuery, [ - orderNo, - customerCode, - deliveryDate || null, - memo || null, - "진행중", - `${userId}|${companyCode}`, - ]); - - const masterObjid = masterResult.rows[0].objid; - - // 수주 상세 (품목) 생성 - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const subObjid = `${orderNo}_${i + 1}`; - - const subQuery = ` - INSERT INTO order_mng_sub ( - objid, - order_mng_master_objid, - part_objid, - partner_objid, - partner_price, - partner_qty, - delivery_date, - status, - regdate, - writer - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9) - `; - - await pool.query(subQuery, [ - subObjid, - masterObjid, - item.item_code || item.id, // 품목 코드 - customerCode, - item.unit_price || 0, - item.quantity || 0, - item.delivery_date || deliveryDate || null, - "진행중", - `${userId}|${companyCode}`, - ]); - } - - logger.info("수주 등록 성공", { - companyCode, - orderNo, - masterObjid, - itemCount: items.length, - totalAmount, - }); - - res.json({ - success: true, - data: { - orderNo, - masterObjid, - itemCount: items.length, - totalAmount, - }, - message: "수주가 등록되었습니다", - }); - } catch (error: any) { - logger.error("수주 등록 오류", { - error: error.message, - stack: error.stack, - }); - res.status(500).json({ - success: false, - message: error.message || "수주 등록 중 오류가 발생했습니다", - }); - } -} - -/** - * 수주 목록 조회 API (마스터 + 품목 JOIN) - * GET /api/orders - */ -export async function getOrders(req: AuthenticatedRequest, res: Response) { - const pool = getPool(); - - try { - const { page = "1", limit = "20", searchText = "" } = req.query; - const companyCode = req.user!.companyCode; - - const offset = (parseInt(page as string) - 1) * parseInt(limit as string); - - // WHERE 조건 - const whereConditions: string[] = []; - const params: any[] = []; - let paramIndex = 1; - - // 멀티테넌시 (writer 필드에 company_code 포함) - if (companyCode !== "*") { - whereConditions.push(`m.writer LIKE $${paramIndex}`); - params.push(`%${companyCode}%`); - paramIndex++; - } - - // 검색 - if (searchText) { - whereConditions.push(`m.objid LIKE $${paramIndex}`); - params.push(`%${searchText}%`); - paramIndex++; - } - - const whereClause = - whereConditions.length > 0 - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; - - // 카운트 쿼리 (고유한 수주 개수) - const countQuery = ` - SELECT COUNT(DISTINCT m.objid) as count - FROM order_mng_master m - ${whereClause} - `; - const countResult = await pool.query(countQuery, params); - const total = parseInt(countResult.rows[0]?.count || "0"); - - // 데이터 쿼리 (마스터 + 품목 JOIN) - const dataQuery = ` - SELECT - m.objid as order_no, - m.partner_objid, - m.final_delivery_date, - m.reason, - m.status, - m.reg_date, - m.writer, - COALESCE( - json_agg( - CASE WHEN s.objid IS NOT NULL THEN - json_build_object( - 'sub_objid', s.objid, - 'part_objid', s.part_objid, - 'partner_price', s.partner_price, - 'partner_qty', s.partner_qty, - 'delivery_date', s.delivery_date, - 'status', s.status, - 'regdate', s.regdate - ) - END - ORDER BY s.regdate - ) FILTER (WHERE s.objid IS NOT NULL), - '[]'::json - ) as items - FROM order_mng_master m - LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid - ${whereClause} - GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer - ORDER BY m.reg_date DESC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} - `; - - params.push(parseInt(limit as string)); - params.push(offset); - - const dataResult = await pool.query(dataQuery, params); - - logger.info("수주 목록 조회 성공", { - companyCode, - total, - page: parseInt(page as string), - itemCount: dataResult.rows.length, - }); - - res.json({ - success: true, - data: dataResult.rows, - pagination: { - total, - page: parseInt(page as string), - limit: parseInt(limit as string), - }, - }); - } catch (error: any) { - logger.error("수주 목록 조회 오류", { error: error.message }); - res.status(500).json({ - success: false, - message: error.message, - }); - } -} diff --git a/backend-node/src/routes/orderRoutes.ts b/backend-node/src/routes/orderRoutes.ts deleted file mode 100644 index a59b5f43..00000000 --- a/backend-node/src/routes/orderRoutes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Router } from "express"; -import { authenticateToken } from "../middleware/authMiddleware"; -import { createOrder, getOrders } from "../controllers/orderController"; - -const router = Router(); - -/** - * 수주 등록 - * POST /api/orders - */ -router.post("/", authenticateToken, createOrder); - -/** - * 수주 목록 조회 - * GET /api/orders - */ -router.get("/", authenticateToken, getOrders); - -export default router; - diff --git a/frontend/components/order/OrderCustomerSearch.tsx b/frontend/components/order/OrderCustomerSearch.tsx deleted file mode 100644 index bcd351f9..00000000 --- a/frontend/components/order/OrderCustomerSearch.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import React from "react"; -import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input"; - -/** - * 수주 등록 전용 거래처 검색 컴포넌트 - * - * 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다. - * 범용 AutocompleteSearchInput과 달리 customer_mng 테이블만 조회합니다. - */ - -interface OrderCustomerSearchProps { - /** 현재 선택된 거래처 코드 */ - value: string; - /** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */ - onChange: (customerCode: string | null, fullData?: any) => void; - /** 비활성화 여부 */ - disabled?: boolean; -} - -export function OrderCustomerSearch({ - value, - onChange, - disabled = false, -}: OrderCustomerSearchProps) { - return ( - - ); -} - diff --git a/frontend/components/order/OrderItemRepeaterTable.tsx b/frontend/components/order/OrderItemRepeaterTable.tsx deleted file mode 100644 index dbfe5eee..00000000 --- a/frontend/components/order/OrderItemRepeaterTable.tsx +++ /dev/null @@ -1,135 +0,0 @@ -"use client"; - -import React from "react"; -import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table"; -import type { - RepeaterColumnConfig, - CalculationRule, -} from "@/lib/registry/components/modal-repeater-table"; - -/** - * 수주 등록 전용 품목 반복 테이블 컴포넌트 - * - * 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다. - * 범용 ModalRepeaterTable과 달리 item_info 테이블만 조회하며, - * 수주 등록에 필요한 컬럼과 계산 공식이 미리 설정되어 있습니다. - */ - -interface OrderItemRepeaterTableProps { - /** 현재 선택된 품목 목록 */ - value: any[]; - /** 품목 목록 변경 시 콜백 */ - onChange: (items: any[]) => void; - /** 비활성화 여부 */ - disabled?: boolean; -} - -// 수주 등록 전용 컬럼 설정 (고정) -const ORDER_COLUMNS: RepeaterColumnConfig[] = [ - { - field: "item_number", - label: "품번", - editable: false, - width: "120px", - }, - { - field: "item_name", - label: "품명", - editable: false, - width: "180px", - }, - { - field: "specification", - label: "규격", - editable: false, - width: "150px", - }, - { - field: "material", - label: "재질", - editable: false, - width: "120px", - }, - { - field: "quantity", - label: "수량", - type: "number", - editable: true, - required: true, - defaultValue: 1, - width: "100px", - }, - { - field: "selling_price", - label: "단가", - type: "number", - editable: true, - required: true, - width: "120px", - }, - { - field: "amount", - label: "금액", - type: "number", - editable: false, - calculated: true, - width: "120px", - }, - { - field: "order_date", - label: "수주일", - type: "date", - editable: true, - width: "130px", - }, - { - field: "delivery_date", - label: "납기일", - type: "date", - editable: true, - width: "130px", - }, -]; - -// 수주 등록 전용 계산 공식 (고정) -const ORDER_CALCULATION_RULES: CalculationRule[] = [ - { - result: "amount", - formula: "quantity * selling_price", - dependencies: ["quantity", "selling_price"], - }, -]; - -export function OrderItemRepeaterTable({ - value, - onChange, - disabled = false, -}: OrderItemRepeaterTableProps) { - return ( - - ); -} - diff --git a/frontend/components/order/OrderRegistrationModal.tsx b/frontend/components/order/OrderRegistrationModal.tsx deleted file mode 100644 index e47e124f..00000000 --- a/frontend/components/order/OrderRegistrationModal.tsx +++ /dev/null @@ -1,572 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { OrderCustomerSearch } from "./OrderCustomerSearch"; -import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable"; -import { toast } from "sonner"; -import { apiClient } from "@/lib/api/client"; - -interface OrderRegistrationModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSuccess?: () => void; -} - -export function OrderRegistrationModal({ - open, - onOpenChange, - onSuccess, -}: OrderRegistrationModalProps) { - // 입력 방식 - const [inputMode, setInputMode] = useState("customer_first"); - - // 판매 유형 (국내/해외) - const [salesType, setSalesType] = useState("domestic"); - - // 단가 기준 (기준단가/거래처별단가) - const [priceType, setPriceType] = useState("standard"); - - // 폼 데이터 - const [formData, setFormData] = useState({ - customerCode: "", - customerName: "", - contactPerson: "", - deliveryDestination: "", - deliveryAddress: "", - deliveryDate: "", - memo: "", - // 무역 정보 (해외 판매 시) - incoterms: "", - paymentTerms: "", - currency: "KRW", - portOfLoading: "", - portOfDischarge: "", - hsCode: "", - }); - - // 선택된 품목 목록 - const [selectedItems, setSelectedItems] = useState([]); - - // 납기일 일괄 적용 플래그 (딱 한 번만 실행) - const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); - - // 저장 중 - const [isSaving, setIsSaving] = useState(false); - - // 저장 처리 - const handleSave = async () => { - try { - // 유효성 검사 - if (!formData.customerCode) { - toast.error("거래처를 선택해주세요"); - return; - } - - if (selectedItems.length === 0) { - toast.error("품목을 추가해주세요"); - return; - } - - setIsSaving(true); - - // 수주 등록 API 호출 - const orderData: any = { - inputMode, - salesType, - priceType, - customerCode: formData.customerCode, - contactPerson: formData.contactPerson, - deliveryDestination: formData.deliveryDestination, - deliveryAddress: formData.deliveryAddress, - deliveryDate: formData.deliveryDate, - items: selectedItems, - memo: formData.memo, - }; - - // 해외 판매 시 무역 정보 추가 - if (salesType === "export") { - orderData.tradeInfo = { - incoterms: formData.incoterms, - paymentTerms: formData.paymentTerms, - currency: formData.currency, - portOfLoading: formData.portOfLoading, - portOfDischarge: formData.portOfDischarge, - hsCode: formData.hsCode, - }; - } - - const response = await apiClient.post("/orders", orderData); - - if (response.data.success) { - toast.success("수주가 등록되었습니다"); - onOpenChange(false); - onSuccess?.(); - - // 폼 초기화 - resetForm(); - } else { - toast.error(response.data.message || "수주 등록에 실패했습니다"); - } - } catch (error: any) { - console.error("수주 등록 오류:", error); - toast.error( - error.response?.data?.message || "수주 등록 중 오류가 발생했습니다" - ); - } finally { - setIsSaving(false); - } - }; - - // 취소 처리 - const handleCancel = () => { - onOpenChange(false); - resetForm(); - }; - - // 폼 초기화 - const resetForm = () => { - setInputMode("customer_first"); - setSalesType("domestic"); - setPriceType("standard"); - setFormData({ - customerCode: "", - customerName: "", - contactPerson: "", - deliveryDestination: "", - deliveryAddress: "", - deliveryDate: "", - memo: "", - incoterms: "", - paymentTerms: "", - currency: "KRW", - portOfLoading: "", - portOfDischarge: "", - hsCode: "", - }); - setSelectedItems([]); - setIsDeliveryDateApplied(false); // 플래그 초기화 - }; - - // 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함) - const handleItemsChange = (newItems: any[]) => { - // 1️⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태) - if (isDeliveryDateApplied) { - setSelectedItems(newItems); - return; - } - - // 2️⃣ 품목이 없으면 그냥 업데이트 - if (newItems.length === 0) { - setSelectedItems(newItems); - return; - } - - // 3️⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크 - const itemsWithDate = newItems.filter((item) => item.delivery_date); - const itemsWithoutDate = newItems.filter((item) => !item.delivery_date); - - // 4️⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용 - if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) { - // 5️⃣ 전체 일괄 적용 - const selectedDate = itemsWithDate[0].delivery_date; - const updatedItems = newItems.map((item) => ({ - ...item, - delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용 - })); - - setSelectedItems(updatedItems); - setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함) - - console.log("✅ 납기일 일괄 적용 완료:", selectedDate); - console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`); - } else { - // 그냥 업데이트 - setSelectedItems(newItems); - } - }; - - // 전체 금액 계산 - const totalAmount = selectedItems.reduce( - (sum, item) => sum + (item.amount || 0), - 0 - ); - - return ( - - - - 수주 등록 - - 새로운 수주를 등록합니다 - - - -
- {/* 상단 셀렉트 박스 3개 */} -
- {/* 입력 방식 */} -
- - -
- - {/* 판매 유형 */} -
- - -
- - {/* 단가 기준 */} -
- - -
-
- - {/* 거래처 정보 (항상 표시) */} - {inputMode === "customer_first" && ( -
-
- 🏢 - 거래처 정보 -
- -
- {/* 거래처 */} -
- - { - setFormData({ - ...formData, - customerCode: code || "", - customerName: fullData?.customer_name || "", - }); - }} - /> -
- - {/* 담당자 */} -
- - - setFormData({ ...formData, contactPerson: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
- - {/* 납품처 */} -
- - - setFormData({ ...formData, deliveryDestination: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
- - {/* 납품장소 */} -
- - - setFormData({ ...formData, deliveryAddress: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
-
-
- )} - - {inputMode === "quotation" && ( -
-
- - -
-
- )} - - {inputMode === "unit_price" && ( -
-
- - -
-
- )} - - {/* 추가된 품목 */} -
- - -
- - {/* 전체 금액 표시 */} - {selectedItems.length > 0 && ( -
-
- 전체 금액: {totalAmount.toLocaleString()}원 -
-
- )} - - {/* 무역 정보 (해외 판매 시에만 표시) */} - {salesType === "export" && ( -
-
- 🌏 - 무역 정보 -
- -
- {/* 인코텀즈 */} -
- - -
- - {/* 결제 조건 */} -
- - -
- - {/* 통화 */} -
- - -
-
- -
- {/* 선적항 */} -
- - - setFormData({ ...formData, portOfLoading: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
- - {/* 도착항 */} -
- - - setFormData({ ...formData, portOfDischarge: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
- - {/* HS Code */} -
- - - setFormData({ ...formData, hsCode: e.target.value }) - } - className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm" - /> -
-
-
- )} - - {/* 메모 */} -
- -