From 64e6fd19206c49c3128314e4825f73c8d2c2d6dc Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 14 Nov 2025 14:43:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EC=A3=BC=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EB=B0=8F=20=EB=B2=94=EC=9A=A9=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 범용 컴포넌트 3종 개발 및 레지스트리 등록: * AutocompleteSearchInput: 자동완성 검색 입력 컴포넌트 * EntitySearchInput: 엔티티 검색 모달 컴포넌트 * ModalRepeaterTable: 모달 기반 반복 테이블 컴포넌트 - 수주등록 전용 컴포넌트: * OrderCustomerSearch: 거래처 검색 (AutocompleteSearchInput 래퍼) * OrderItemRepeaterTable: 품목 관리 (ModalRepeaterTable 래퍼) * OrderRegistrationModal: 수주등록 메인 모달 - 백엔드 API: * Entity 검색 API (멀티테넌시 지원) * 수주 등록 API (자동 채번) - 화면 편집기 통합: * 컴포넌트 레지스트리에 등록 * ConfigPanel을 통한 설정 기능 * 드래그앤드롭으로 배치 가능 - 개발 문서: * 수주등록_화면_개발_계획서.md (상세 설계 문서) --- backend-node/src/app.ts | 4 + .../src/controllers/entitySearchController.ts | 109 ++ .../src/controllers/orderController.ts | 238 +++ backend-node/src/routes/entitySearchRoutes.ts | 14 + backend-node/src/routes/orderRoutes.ts | 20 + frontend/app/test-entity-search/page.tsx | 138 ++ frontend/app/test-order-registration/page.tsx | 87 ++ .../components/order/OrderCustomerSearch.tsx | 49 + .../order/OrderItemRepeaterTable.tsx | 128 ++ .../order/OrderRegistrationModal.tsx | 270 ++++ frontend/components/order/README.md | 374 +++++ frontend/components/screen/ScreenDesigner.tsx | 9 + .../screen/panels/ComponentsPanel.tsx | 22 +- .../screen/panels/DetailSettingsPanel.tsx | 58 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 63 +- .../lib/registry/DynamicComponentRenderer.tsx | 4 +- .../AutocompleteSearchInputComponent.tsx | 189 +++ .../AutocompleteSearchInputConfigPanel.tsx | 381 +++++ .../AutocompleteSearchInputRenderer.tsx | 19 + .../autocomplete-search-input/README.md | 40 + .../autocomplete-search-input/index.ts | 43 + .../autocomplete-search-input/types.ts | 11 + .../EntitySearchInputComponent.tsx | 126 ++ .../EntitySearchInputConfigPanel.tsx | 498 ++++++ .../EntitySearchInputRenderer.tsx | 19 + .../entity-search-input/EntitySearchModal.tsx | 223 +++ .../components/entity-search-input/config.ts | 14 + .../components/entity-search-input/index.ts | 53 + .../components/entity-search-input/types.ts | 52 + .../entity-search-input/useEntitySearch.ts | 110 ++ frontend/lib/registry/components/index.ts | 6 + .../ItemSelectionModal.tsx | 265 ++++ .../ModalRepeaterTableComponent.tsx | 137 ++ .../ModalRepeaterTableConfigPanel.tsx | 350 +++++ .../ModalRepeaterTableRenderer.tsx | 19 + .../modal-repeater-table/RepeaterTable.tsx | 179 +++ .../components/modal-repeater-table/index.ts | 52 + .../components/modal-repeater-table/types.ts | 69 + .../modal-repeater-table/useCalculation.ts | 56 + .../OrderRegistrationModalConfigPanel.tsx | 93 ++ .../OrderRegistrationModalRenderer.tsx | 53 + .../order-registration-modal/index.ts | 68 + .../utils/createComponentDefinition.ts | 6 +- .../lib/utils/getComponentConfigPanel.tsx | 22 + frontend/types/component.ts | 1 + 수주등록_화면_개발_계획서.md | 1353 +++++++++++++++++ 46 files changed, 6086 insertions(+), 8 deletions(-) create mode 100644 backend-node/src/controllers/entitySearchController.ts create mode 100644 backend-node/src/controllers/orderController.ts create mode 100644 backend-node/src/routes/entitySearchRoutes.ts create mode 100644 backend-node/src/routes/orderRoutes.ts create mode 100644 frontend/app/test-entity-search/page.tsx create mode 100644 frontend/app/test-order-registration/page.tsx create mode 100644 frontend/components/order/OrderCustomerSearch.tsx create mode 100644 frontend/components/order/OrderItemRepeaterTable.tsx create mode 100644 frontend/components/order/OrderRegistrationModal.tsx create mode 100644 frontend/components/order/README.md create mode 100644 frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx create mode 100644 frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx create mode 100644 frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer.tsx create mode 100644 frontend/lib/registry/components/autocomplete-search-input/README.md create mode 100644 frontend/lib/registry/components/autocomplete-search-input/index.ts create mode 100644 frontend/lib/registry/components/autocomplete-search-input/types.ts create mode 100644 frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx create mode 100644 frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx create mode 100644 frontend/lib/registry/components/entity-search-input/EntitySearchInputRenderer.tsx create mode 100644 frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx create mode 100644 frontend/lib/registry/components/entity-search-input/config.ts create mode 100644 frontend/lib/registry/components/entity-search-input/index.ts create mode 100644 frontend/lib/registry/components/entity-search-input/types.ts create mode 100644 frontend/lib/registry/components/entity-search-input/useEntitySearch.ts create mode 100644 frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx create mode 100644 frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx create mode 100644 frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx create mode 100644 frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer.tsx create mode 100644 frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx create mode 100644 frontend/lib/registry/components/modal-repeater-table/index.ts create mode 100644 frontend/lib/registry/components/modal-repeater-table/types.ts create mode 100644 frontend/lib/registry/components/modal-repeater-table/useCalculation.ts create mode 100644 frontend/lib/registry/components/order-registration-modal/OrderRegistrationModalConfigPanel.tsx create mode 100644 frontend/lib/registry/components/order-registration-modal/OrderRegistrationModalRenderer.tsx create mode 100644 frontend/lib/registry/components/order-registration-modal/index.ts create mode 100644 수주등록_화면_개발_계획서.md diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 37936f36..07389374 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -68,6 +68,8 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 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 { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -230,6 +232,8 @@ app.use("/api/departments", departmentRoutes); // 부서 관리 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/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts new file mode 100644 index 00000000..2b944e2b --- /dev/null +++ b/backend-node/src/controllers/entitySearchController.ts @@ -0,0 +1,109 @@ +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 엔티티 검색 API + * GET /api/entity-search/:tableName + */ +export async function searchEntity(req: Request, res: Response) { + try { + const { tableName } = req.params; + const { + searchText = "", + searchFields = "", + filterCondition = "{}", + page = "1", + limit = "20", + } = req.query; + + // 멀티테넌시 + const companyCode = req.user!.companyCode; + + // 검색 필드 파싱 + const fields = searchFields + ? (searchFields as string).split(",").map((f) => f.trim()) + : []; + + // WHERE 조건 생성 + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // 검색 조건 + if (searchText && fields.length > 0) { + const searchConditions = fields.map((field) => { + const condition = `${field}::text ILIKE $${paramIndex}`; + paramIndex++; + return condition; + }); + whereConditions.push(`(${searchConditions.join(" OR ")})`); + + // 검색어 파라미터 추가 + fields.forEach(() => { + params.push(`%${searchText}%`); + }); + } + + // 추가 필터 조건 + const additionalFilter = JSON.parse(filterCondition as string); + for (const [key, value] of Object.entries(additionalFilter)) { + whereConditions.push(`${key} = $${paramIndex}`); + params.push(value); + paramIndex++; + } + + // 페이징 + const offset = (parseInt(page as string) - 1) * parseInt(limit as string); + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // 쿼리 실행 + const pool = getPool(); + const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; + const dataQuery = ` + SELECT * FROM ${tableName} ${whereClause} + ORDER BY id DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + params.push(parseInt(limit as string)); + params.push(offset); + + const countResult = await pool.query( + countQuery, + params.slice(0, params.length - 2) + ); + const dataResult = await pool.query(dataQuery, params); + + logger.info("엔티티 검색 성공", { + tableName, + searchText, + companyCode, + rowCount: dataResult.rowCount, + }); + + res.json({ + success: true, + data: dataResult.rows, + pagination: { + total: parseInt(countResult.rows[0].count), + page: parseInt(page as string), + limit: parseInt(limit as string), + }, + }); + } catch (error: any) { + logger.error("엔티티 검색 오류", { error: error.message, stack: error.stack }); + res.status(500).json({ success: false, message: error.message }); + } +} + diff --git a/backend-node/src/controllers/orderController.ts b/backend-node/src/controllers/orderController.ts new file mode 100644 index 00000000..0b76fd95 --- /dev/null +++ b/backend-node/src/controllers/orderController.ts @@ -0,0 +1,238 @@ +import { Request, Response } from "express"; +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: Request, 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 + * GET /api/orders + */ +export async function getOrders(req: Request, 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(`writer LIKE $${paramIndex}`); + params.push(`%${companyCode}%`); + paramIndex++; + } + + // 검색 + if (searchText) { + whereConditions.push(`objid LIKE $${paramIndex}`); + params.push(`%${searchText}%`); + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // 카운트 쿼리 + const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`; + const countResult = await pool.query(countQuery, params); + const total = parseInt(countResult.rows[0]?.count || "0"); + + // 데이터 쿼리 + const dataQuery = ` + SELECT * FROM order_mng_master + ${whereClause} + ORDER BY reg_date DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + params.push(parseInt(limit as string)); + params.push(offset); + + const dataResult = await pool.query(dataQuery, params); + + 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/entitySearchRoutes.ts b/backend-node/src/routes/entitySearchRoutes.ts new file mode 100644 index 00000000..7677279a --- /dev/null +++ b/backend-node/src/routes/entitySearchRoutes.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { searchEntity } from "../controllers/entitySearchController"; + +const router = Router(); + +/** + * 엔티티 검색 API + * GET /api/entity-search/:tableName + */ +router.get("/:tableName", authenticateToken, searchEntity); + +export default router; + diff --git a/backend-node/src/routes/orderRoutes.ts b/backend-node/src/routes/orderRoutes.ts new file mode 100644 index 00000000..a59b5f43 --- /dev/null +++ b/backend-node/src/routes/orderRoutes.ts @@ -0,0 +1,20 @@ +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/app/test-entity-search/page.tsx b/frontend/app/test-entity-search/page.tsx new file mode 100644 index 00000000..af9317a3 --- /dev/null +++ b/frontend/app/test-entity-search/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import React, { useState } from "react"; +import { EntitySearchInputComponent } from "@/lib/registry/components/entity-search-input"; +import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; + +export default function TestEntitySearchPage() { + const [customerCode, setCustomerCode] = useState(""); + const [customerData, setCustomerData] = useState(null); + + const [itemCode, setItemCode] = useState(""); + const [itemData, setItemData] = useState(null); + + return ( +
+
+

EntitySearchInput 테스트

+

+ 엔티티 검색 입력 컴포넌트 동작 테스트 +

+
+ + {/* 거래처 검색 테스트 - 자동완성 방식 */} + + + 거래처 검색 (자동완성 드롭다운 방식) ⭐ NEW + + 타이핑하면 바로 드롭다운이 나타나는 방식 - 수주 등록에서 사용 + + + +
+ + { + setCustomerCode(code || ""); + setCustomerData(fullData); + }} + /> +
+ + {customerData && ( +
+

선택된 거래처 정보:

+
+                {JSON.stringify(customerData, null, 2)}
+              
+
+ )} +
+
+ + {/* 거래처 검색 테스트 - 모달 방식 */} + + + 거래처 검색 (모달 방식) + + 버튼 클릭 → 모달 열기 → 검색 및 선택 방식 + + + +
+ + { + setCustomerCode(code || ""); + setCustomerData(fullData); + }} + /> +
+
+
+ + {/* 품목 검색 테스트 */} + + + 품목 검색 (Modal 모드) + + item_info 테이블에서 품목을 검색합니다 + + + +
+ + { + setItemCode(code || ""); + setItemData(fullData); + }} + /> +
+ + {itemData && ( +
+

선택된 품목 정보:

+
+                {JSON.stringify(itemData, null, 2)}
+              
+
+ )} +
+
+
+ ); +} + diff --git a/frontend/app/test-order-registration/page.tsx b/frontend/app/test-order-registration/page.tsx new file mode 100644 index 00000000..689c5434 --- /dev/null +++ b/frontend/app/test-order-registration/page.tsx @@ -0,0 +1,87 @@ +"use client"; + +import React, { useState } from "react"; +import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function TestOrderRegistrationPage() { + const [modalOpen, setModalOpen] = useState(false); + + const handleSuccess = () => { + console.log("수주 등록 성공!"); + }; + + return ( +
+
+

수주 등록 테스트

+

+ EntitySearchInput + ModalRepeaterTable을 활용한 수주 등록 화면 +

+
+ + + + 수주 등록 모달 + + 모달 버튼을 클릭하여 수주 등록 화면을 테스트하세요 + + + + + + + + + + 구현된 기능 + + +
+ + EntitySearchInput: 거래처 검색 및 선택 (콤보 모드) +
+
+ + ModalRepeaterTable: 품목 검색 및 동적 추가 +
+
+ + 자동 계산: 수량 × 단가 = 금액 +
+
+ + 인라인 편집: 수량, 단가, 납품일, 비고 수정 가능 +
+
+ + 중복 방지: 이미 추가된 품목은 선택 불가 +
+
+ + 행 삭제: 추가된 품목 개별 삭제 가능 +
+
+ + 전체 금액 표시: 모든 품목 금액의 합계 +
+
+ + 입력 방식 전환: 거래처 우선 / 견대 방식 / 단가 방식 +
+
+
+ + {/* 수주 등록 모달 */} + +
+ ); +} + diff --git a/frontend/components/order/OrderCustomerSearch.tsx b/frontend/components/order/OrderCustomerSearch.tsx new file mode 100644 index 00000000..bcd351f9 --- /dev/null +++ b/frontend/components/order/OrderCustomerSearch.tsx @@ -0,0 +1,49 @@ +"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 new file mode 100644 index 00000000..19e31502 --- /dev/null +++ b/frontend/components/order/OrderItemRepeaterTable.tsx @@ -0,0 +1,128 @@ +"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: "id", + label: "품번", + editable: false, + width: "120px", + }, + { + field: "item_name", + label: "품명", + editable: false, + width: "200px", + }, + { + field: "item_number", + label: "품목번호", + editable: false, + width: "150px", + }, + { + 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: "delivery_date", + label: "납품일", + type: "date", + editable: true, + width: "130px", + }, + { + field: "note", + label: "비고", + type: "text", + editable: true, + width: "200px", + }, +]; + +// 수주 등록 전용 계산 공식 (고정) +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 new file mode 100644 index 00000000..7a454cc1 --- /dev/null +++ b/frontend/components/order/OrderRegistrationModal.tsx @@ -0,0 +1,270 @@ +"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 { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +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 [formData, setFormData] = useState({ + customerCode: "", + customerName: "", + deliveryDate: "", + memo: "", + }); + + // 선택된 품목 목록 + const [selectedItems, setSelectedItems] = useState([]); + + // 저장 중 + 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 response = await apiClient.post("/orders", { + inputMode, + customerCode: formData.customerCode, + deliveryDate: formData.deliveryDate, + items: selectedItems, + memo: formData.memo, + }); + + 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"); + setFormData({ + customerCode: "", + customerName: "", + deliveryDate: "", + memo: "", + }); + setSelectedItems([]); + }; + + // 전체 금액 계산 + const totalAmount = selectedItems.reduce( + (sum, item) => sum + (item.amount || 0), + 0 + ); + + return ( + + + + 수주 등록 + + 새로운 수주를 등록합니다 + + + +
+ {/* 입력 방식 선택 */} +
+ + +
+ + {/* 입력 방식에 따른 동적 폼 */} + {inputMode === "customer_first" && ( +
+ {/* 거래처 검색 */} +
+ + { + setFormData({ + ...formData, + customerCode: code || "", + customerName: fullData?.customer_name || "", + }); + }} + /> +
+ + {/* 납품일 */} +
+ + + setFormData({ ...formData, deliveryDate: e.target.value }) + } + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ )} + + {inputMode === "quotation" && ( +
+
+ + +
+
+ )} + + {inputMode === "unit_price" && ( +
+
+ + +
+
+ )} + + {/* 추가된 품목 */} +
+ + +
+ + {/* 전체 금액 표시 */} + {selectedItems.length > 0 && ( +
+
+ 전체 금액: {totalAmount.toLocaleString()}원 +
+
+ )} + + {/* 메모 */} +
+ +