# ๐Ÿ“‹ ์ˆ˜์ฃผ ๋“ฑ๋ก ํ™”๋ฉด ๊ฐœ๋ฐœ ๊ณ„ํš์„œ ## ๐Ÿ“Œ ํ”„๋กœ์ ํŠธ ๊ฐœ์š” **๋ชฉํ‘œ**: ์ˆ˜์ฃผ ๋“ฑ๋ก ๋ชจ๋‹ฌ ํ™”๋ฉด ๊ตฌํ˜„์„ ์œ„ํ•œ ๋ฒ”์šฉ ์ปดํฌ๋„ŒํŠธ ๊ฐœ๋ฐœ ๋ฐ ์ „์šฉ ํ™”๋ฉด ๊ตฌ์„ฑ **๊ธฐ๊ฐ„**: ์•ฝ 4-5์ผ **์ „๋žต**: ํ•ต์‹ฌ ๋ฒ”์šฉ ์ปดํฌ๋„ŒํŠธ ์šฐ์„  ๊ฐœ๋ฐœ โ†’ ์ „์šฉ ํ™”๋ฉด ๊ตฌ์„ฑ โ†’ ์ ์ง„์  ํ™•์žฅ --- ## ๐ŸŽฏ Phase 1: ๋ฒ”์šฉ ์ปดํฌ๋„ŒํŠธ ๊ฐœ๋ฐœ (2-3์ผ) ### 1.1 EntitySearchInput ์ปดํฌ๋„ŒํŠธ โญโญโญ **๋ชฉ์ **: ์—”ํ‹ฐํ‹ฐ ํ…Œ์ด๋ธ”(๊ฑฐ๋ž˜์ฒ˜, ํ’ˆ๋ชฉ, ์‚ฌ์šฉ์ž ๋“ฑ)์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ƒ‰ํ•˜๊ณ  ์„ ํƒํ•˜๋Š” ๋ฒ”์šฉ ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ #### ์ฃผ์š” ๊ธฐ๋Šฅ - ์ž๋™์™„์„ฑ ๊ฒ€์ƒ‰ (ํƒ€์ดํ•‘ ์‹œ ์‹ค์‹œ๊ฐ„ ๊ฒ€์ƒ‰) - ๋ชจ๋‹ฌ ๊ฒ€์ƒ‰ (๋ฒ„ํŠผ ํด๋ฆญ โ†’ ์ „์ฒด ๋ชฉ๋ก + ๊ฒ€์ƒ‰) - ์ฝค๋ณด ๋ชจ๋“œ (์ž…๋ ฅ ํ•„๋“œ + ๊ฒ€์ƒ‰ ๋ฒ„ํŠผ) - ๋‹ค์ค‘ ํ•„๋“œ ๊ฒ€์ƒ‰ ์ง€์› - ์„ ํƒ๋œ ํ•ญ๋ชฉ ํ‘œ์‹œ ๋ฐ ์ดˆ๊ธฐํ™” #### ์ธํ„ฐํŽ˜์ด์Šค ์„ค๊ณ„ ```typescript // frontend/lib/registry/components/entity-search-input/types.ts export interface EntitySearchInputProps { // ๋ฐ์ดํ„ฐ ์†Œ์Šค tableName: string; // ๊ฒ€์ƒ‰ํ•  ํ…Œ์ด๋ธ”๋ช… (์˜ˆ: "customer_mng") displayField: string; // ํ‘œ์‹œํ•  ํ•„๋“œ (์˜ˆ: "customer_name") valueField: string; // ๊ฐ’์œผ๋กœ ์‚ฌ์šฉํ•  ํ•„๋“œ (์˜ˆ: "customer_code") searchFields?: string[]; // ๊ฒ€์ƒ‰ ๋Œ€์ƒ ํ•„๋“œ๋“ค (๊ธฐ๋ณธ: [displayField]) // UI ๋ชจ๋“œ mode?: "autocomplete" | "modal" | "combo"; // ๊ธฐ๋ณธ: "combo" placeholder?: string; disabled?: boolean; // ํ•„ํ„ฐ๋ง filterCondition?: Record; // ์ถ”๊ฐ€ WHERE ์กฐ๊ฑด companyCode?: string; // ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ // ์„ ํƒ๋œ ๊ฐ’ value?: any; onChange?: (value: any, fullData?: any) => void; // ๋ชจ๋‹ฌ ์„ค์ • (mode๊ฐ€ "modal" ๋˜๋Š” "combo"์ผ ๋•Œ) modalTitle?: string; modalColumns?: string[]; // ๋ชจ๋‹ฌ์— ํ‘œ์‹œํ•  ์ปฌ๋Ÿผ๋“ค // ์ถ”๊ฐ€ ํ‘œ์‹œ ์ •๋ณด showAdditionalInfo?: boolean; // ์„ ํƒ ํ›„ ์ถ”๊ฐ€ ์ •๋ณด ํ‘œ์‹œ (์˜ˆ: ์ฃผ์†Œ) additionalFields?: string[]; // ์ถ”๊ฐ€๋กœ ํ‘œ์‹œํ•  ํ•„๋“œ๋“ค } ``` #### ํŒŒ์ผ ๊ตฌ์กฐ ``` frontend/lib/registry/components/entity-search-input/ โ”œโ”€โ”€ EntitySearchInputComponent.tsx # ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ โ”œโ”€โ”€ EntitySearchModal.tsx # ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ โ”œโ”€โ”€ types.ts # ํƒ€์ž… ์ •์˜ โ”œโ”€โ”€ useEntitySearch.ts # ๊ฒ€์ƒ‰ ๋กœ์ง ํ›… โ””โ”€โ”€ EntitySearchInputConfig.tsx # ์†์„ฑ ํŽธ์ง‘ ํŒจ๋„ ``` #### API ์—”๋“œํฌ์ธํŠธ (๋ฐฑ์—”๋“œ) ```typescript // backend-node/src/controllers/entitySearchController.ts /** * GET /api/entity-search/:tableName * Query params: * - searchText: ๊ฒ€์ƒ‰์–ด * - searchFields: ๊ฒ€์ƒ‰ํ•  ํ•„๋“œ๋“ค (์ฝค๋งˆ ๊ตฌ๋ถ„) * - filterCondition: JSON ํ˜•์‹์˜ ์ถ”๊ฐ€ ์กฐ๊ฑด * - page, limit: ํŽ˜์ด์ง• */ router.get("/api/entity-search/:tableName", async (req, res) => { const { tableName } = req.params; const { searchText, searchFields, filterCondition, page = 1, limit = 20, } = req.query; // ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ ์ž๋™ ์ ์šฉ const companyCode = req.user.companyCode; // ๊ฒ€์ƒ‰ ์‹คํ–‰ const results = await entitySearchService.search({ tableName, searchText, searchFields: searchFields?.split(","), filterCondition: filterCondition ? JSON.parse(filterCondition) : {}, companyCode, page: parseInt(page), limit: parseInt(limit), }); res.json({ success: true, data: results }); }); ``` #### ์‚ฌ์šฉ ์˜ˆ์‹œ ```tsx // ๊ฑฐ๋ž˜์ฒ˜ ๊ฒ€์ƒ‰ - ์ฝค๋ณด ๋ชจ๋“œ (์ž…๋ ฅ + ๋ฒ„ํŠผ) { setFormData({ ...formData, customerCode: code, customerName: fullData.customer_name, customerAddress: fullData.address, }); }} /> // ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰ - ๋ชจ๋‹ฌ ์ „์šฉ { setFormData({ ...formData, itemCode: code, itemName: fullData.item_name, unitPrice: fullData.unit_price, }); }} /> // ์‚ฌ์šฉ์ž ๊ฒ€์ƒ‰ - ์ž๋™์™„์„ฑ ์ „์šฉ { setFormData({ ...formData, userId, userName: userData.user_name, }); }} /> ``` --- ### 1.2 ModalRepeaterTable ์ปดํฌ๋„ŒํŠธ โญโญ **๋ชฉ์ **: ๋ชจ๋‹ฌ์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ƒ‰ํ•˜์—ฌ ์„ ํƒํ•˜๊ณ , ์„ ํƒ๋œ ํ•ญ๋ชฉ๋“ค์„ ๋™์  ํ…Œ์ด๋ธ”(Repeater)์— ์ถ”๊ฐ€ํ•˜๋Š” ๋ฒ”์šฉ ์ปดํฌ๋„ŒํŠธ #### ์ฃผ์š” ๊ธฐ๋Šฅ - ๋ชจ๋‹ฌ ๋ฒ„ํŠผ ํด๋ฆญ โ†’ ์†Œ์Šค ํ…Œ์ด๋ธ” ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ ์—ด๊ธฐ - ๋‹ค์ค‘ ์„ ํƒ ์ง€์› (์ฒดํฌ๋ฐ•์Šค) - ์„ ํƒํ•œ ํ•ญ๋ชฉ๋“ค์„ Repeater ํ…Œ์ด๋ธ”์— ์ถ”๊ฐ€ - ์ถ”๊ฐ€๋œ ํ–‰์˜ ํ•„๋“œ ํŽธ์ง‘ ๊ฐ€๋Šฅ (์ˆ˜๋Ÿ‰, ๋‹จ๊ฐ€ ๋“ฑ) - ๊ณ„์‚ฐ ํ•„๋“œ ์ง€์› (์ˆ˜๋Ÿ‰ ร— ๋‹จ๊ฐ€ = ๊ธˆ์•ก) - ํ–‰ ์‚ญ์ œ ๊ธฐ๋Šฅ - ์ค‘๋ณต ๋ฐฉ์ง€ (์ด๋ฏธ ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ์€ ์„ ํƒ ๋ถˆ๊ฐ€) #### ์ธํ„ฐํŽ˜์ด์Šค ์„ค๊ณ„ ```typescript // frontend/lib/registry/components/modal-repeater-table/types.ts export interface ModalRepeaterTableProps { // ์†Œ์Šค ๋ฐ์ดํ„ฐ (๋ชจ๋‹ฌ์—์„œ ๊ฐ€์ ธ์˜ฌ ๋ฐ์ดํ„ฐ) sourceTable: string; // ๊ฒ€์ƒ‰ํ•  ํ…Œ์ด๋ธ” (์˜ˆ: "item_info") sourceColumns: string[]; // ๋ชจ๋‹ฌ์— ํ‘œ์‹œํ•  ์ปฌ๋Ÿผ๋“ค sourceSearchFields?: string[]; // ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ๋“ค // ๋ชจ๋‹ฌ ์„ค์ • modalTitle: string; // ๋ชจ๋‹ฌ ์ œ๋ชฉ (์˜ˆ: "ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰ ๋ฐ ์„ ํƒ") modalButtonText?: string; // ๋ชจ๋‹ฌ ์—ด๊ธฐ ๋ฒ„ํŠผ ํ…์ŠคํŠธ (๊ธฐ๋ณธ: "ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰") multiSelect?: boolean; // ๋‹ค์ค‘ ์„ ํƒ ํ—ˆ์šฉ (๊ธฐ๋ณธ: true) // Repeater ํ…Œ์ด๋ธ” ์„ค์ • columns: RepeaterColumnConfig[]; // ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์„ค์ • // ๊ณ„์‚ฐ ๊ทœ์น™ calculationRules?: CalculationRule[]; // ์ž๋™ ๊ณ„์‚ฐ ๊ทœ์น™ // ๋ฐ์ดํ„ฐ value: any[]; // ํ˜„์žฌ ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ๋“ค onChange: (newData: any[]) => void; // ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์ฝœ๋ฐฑ // ์ค‘๋ณต ์ฒดํฌ uniqueField?: string; // ์ค‘๋ณต ์ฒดํฌํ•  ํ•„๋“œ (์˜ˆ: "item_code") // ํ•„ํ„ฐ๋ง filterCondition?: Record; companyCode?: string; } export interface RepeaterColumnConfig { field: string; // ํ•„๋“œ๋ช… label: string; // ์ปฌ๋Ÿผ ํ—ค๋” ๋ผ๋ฒจ type?: "text" | "number" | "date" | "select"; // ์ž…๋ ฅ ํƒ€์ž… editable?: boolean; // ํŽธ์ง‘ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ calculated?: boolean; // ๊ณ„์‚ฐ ํ•„๋“œ ์—ฌ๋ถ€ width?: string; // ์ปฌ๋Ÿผ ๋„ˆ๋น„ required?: boolean; // ํ•„์ˆ˜ ์ž…๋ ฅ ์—ฌ๋ถ€ defaultValue?: any; // ๊ธฐ๋ณธ๊ฐ’ selectOptions?: { value: string; label: string }[]; // select์ผ ๋•Œ ์˜ต์…˜ } export interface CalculationRule { result: string; // ๊ฒฐ๊ณผ๋ฅผ ์ €์žฅํ•  ํ•„๋“œ formula: string; // ๊ณ„์‚ฐ ๊ณต์‹ (์˜ˆ: "quantity * unit_price") dependencies: string[]; // ์˜์กดํ•˜๋Š” ํ•„๋“œ๋“ค (์ž๋™ ์ถ”์ถœ ๊ฐ€๋Šฅ) } ``` #### ํŒŒ์ผ ๊ตฌ์กฐ ``` frontend/lib/registry/components/modal-repeater-table/ โ”œโ”€โ”€ ModalRepeaterTableComponent.tsx # ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ โ”œโ”€โ”€ ItemSelectionModal.tsx # ํ•ญ๋ชฉ ์„ ํƒ ๋ชจ๋‹ฌ โ”œโ”€โ”€ RepeaterTable.tsx # ๋™์  ํ…Œ์ด๋ธ” (ํŽธ์ง‘ ๊ฐ€๋Šฅ) โ”œโ”€โ”€ types.ts # ํƒ€์ž… ์ •์˜ โ”œโ”€โ”€ useCalculation.ts # ๊ณ„์‚ฐ ๋กœ์ง ํ›… โ””โ”€โ”€ ModalRepeaterTableConfig.tsx # ์†์„ฑ ํŽธ์ง‘ ํŒจ๋„ ``` #### ์‚ฌ์šฉ ์˜ˆ์‹œ ```tsx // ํ’ˆ๋ชฉ ์ถ”๊ฐ€ ํ…Œ์ด๋ธ” (์ˆ˜์ฃผ ๋“ฑ๋ก) { setSelectedItems(newItems); // ์ „์ฒด ๊ธˆ์•ก ์žฌ๊ณ„์‚ฐ const totalAmount = newItems.reduce( (sum, item) => sum + (item.amount || 0), 0 ); setFormData({ ...formData, totalAmount }); }} /> ``` #### ์ปดํฌ๋„ŒํŠธ ๋™์ž‘ ํ๋ฆ„ 1. **์ดˆ๊ธฐ ๋ Œ๋”๋ง** - "ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰" ๋ฒ„ํŠผ ํ‘œ์‹œ - ํ˜„์žฌ ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ๋“ค์„ ํ…Œ์ด๋ธ”๋กœ ํ‘œ์‹œ 2. **๋ชจ๋‹ฌ ์—ด๊ธฐ** - ๋ฒ„ํŠผ ํด๋ฆญ โ†’ `ItemSelectionModal` ์—ด๋ฆผ - `sourceTable`์—์„œ ๋ฐ์ดํ„ฐ ์กฐํšŒ (ํŽ˜์ด์ง•, ๊ฒ€์ƒ‰ ์ง€์›) - ์ด๋ฏธ ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ์€ ์ฒดํฌ๋ฐ•์Šค ๋น„ํ™œ์„ฑํ™” (์ค‘๋ณต ๋ฐฉ์ง€) 3. **ํ•ญ๋ชฉ ์„ ํƒ ๋ฐ ์ถ”๊ฐ€** - ์ฒดํฌ๋ฐ•์Šค๋กœ ๋‹ค์ค‘ ์„ ํƒ - "์ถ”๊ฐ€" ๋ฒ„ํŠผ ํด๋ฆญ โ†’ ์„ ํƒ๋œ ํ•ญ๋ชฉ๋“ค์ด `value` ๋ฐฐ์—ด์— ์ถ”๊ฐ€ - `onChange` ์ฝœ๋ฐฑ ํ˜ธ์ถœ 4. **ํŽธ์ง‘** - ์ถ”๊ฐ€๋œ ํ–‰์˜ ํŽธ์ง‘ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ ํด๋ฆญ โ†’ ์ธ๋ผ์ธ ํŽธ์ง‘ - `editable: true`์ธ ํ•„๋“œ๋งŒ ํŽธ์ง‘ ๊ฐ€๋Šฅ - ๊ฐ’ ๋ณ€๊ฒฝ ์‹œ โ†’ ๊ณ„์‚ฐ ํ•„๋“œ ์ž๋™ ์—…๋ฐ์ดํŠธ 5. **๊ณ„์‚ฐ ํ•„๋“œ ์—…๋ฐ์ดํŠธ** - `calculationRules`์— ๋”ฐ๋ผ ์ž๋™ ๊ณ„์‚ฐ - ์˜ˆ: `quantity` ๋˜๋Š” `unit_price` ๋ณ€๊ฒฝ ์‹œ โ†’ `amount` ์ž๋™ ๊ณ„์‚ฐ 6. **ํ–‰ ์‚ญ์ œ** - ๊ฐ ํ–‰์˜ ์‚ญ์ œ ๋ฒ„ํŠผ ํด๋ฆญ โ†’ ํ•ด๋‹น ํ•ญ๋ชฉ ์ œ๊ฑฐ - `onChange` ์ฝœ๋ฐฑ ํ˜ธ์ถœ --- ## ๐ŸŽฏ Phase 2: ๋ฐฑ์—”๋“œ API ๊ฐœ๋ฐœ (1์ผ) ### 2.1 ์—”ํ‹ฐํ‹ฐ ๊ฒ€์ƒ‰ API **ํŒŒ์ผ**: `backend-node/src/controllers/entitySearchController.ts` ```typescript import { Request, Response } from "express"; import pool from "../database/pool"; import logger from "../utils/logger"; /** * ์—”ํ‹ฐํ‹ฐ ๊ฒ€์ƒ‰ * 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(",") : []; // 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 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 }); res.status(500).json({ success: false, message: error.message }); } } ``` **๋ผ์šฐํŠธ ๋“ฑ๋ก**: `backend-node/src/routes/index.ts` ```typescript import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; import { searchEntity } from "../controllers/entitySearchController"; const router = Router(); // ์—”ํ‹ฐํ‹ฐ ๊ฒ€์ƒ‰ router.get("/api/entity-search/:tableName", authenticateToken, searchEntity); export default router; ``` --- ### 2.2 ์ˆ˜์ฃผ ๋“ฑ๋ก API (๊ธฐ๋ณธ) **ํŒŒ์ผ**: `backend-node/src/controllers/orderController.ts` ```typescript import { Request, Response } from "express"; import pool from "../database/pool"; import logger from "../utils/logger"; /** * ์ˆ˜์ฃผ ๋“ฑ๋ก * POST /api/orders */ export async function createOrder(req: Request, res: Response) { const client = await pool.connect(); try { await client.query("BEGIN"); const { inputMode, // ์ž…๋ ฅ ๋ฐฉ์‹ customerCode, // ๊ฑฐ๋ž˜์ฒ˜ ์ฝ”๋“œ deliveryDate, // ๋‚ฉํ’ˆ์ผ items, // ํ’ˆ๋ชฉ ๋ชฉ๋ก memo, // ๋ฉ”๋ชจ } = req.body; // ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ const companyCode = req.user!.companyCode; const userId = req.user!.userId; // ์ˆ˜์ฃผ ๋งˆ์Šคํ„ฐ ์ƒ์„ฑ const orderQuery = ` INSERT INTO order_mng_master ( company_code, order_no, customer_code, input_mode, delivery_date, total_amount, memo, created_by, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) RETURNING * `; // ์ˆ˜์ฃผ ๋ฒˆํ˜ธ ์ž๋™ ์ƒ์„ฑ (์ฑ„๋ฒˆ ๊ทœ์น™ ํ™œ์šฉ - ๋ณ„๋„ ๊ตฌํ˜„ ํ•„์š”) const orderNo = await generateOrderNumber(companyCode); // ์ „์ฒด ๊ธˆ์•ก ๊ณ„์‚ฐ const totalAmount = items.reduce( (sum: number, item: any) => sum + (item.amount || 0), 0 ); const orderResult = await client.query(orderQuery, [ companyCode, orderNo, customerCode, inputMode, deliveryDate, totalAmount, memo, userId, ]); const orderId = orderResult.rows[0].id; // ์ˆ˜์ฃผ ์ƒ์„ธ (ํ’ˆ๋ชฉ) ์ƒ์„ฑ for (const item of items) { const itemQuery = ` INSERT INTO order_mng_sub ( company_code, order_id, item_code, item_name, spec, quantity, unit_price, amount, delivery_date, note ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `; await client.query(itemQuery, [ companyCode, orderId, item.item_code, item.item_name, item.spec, item.quantity, item.unit_price, item.amount, item.delivery_date, item.note, ]); } await client.query("COMMIT"); logger.info("์ˆ˜์ฃผ ๋“ฑ๋ก ์„ฑ๊ณต", { companyCode, orderNo, orderId, itemCount: items.length, }); res.json({ success: true, data: { orderId, orderNo, }, }); } catch (error: any) { await client.query("ROLLBACK"); logger.error("์ˆ˜์ฃผ ๋“ฑ๋ก ์˜ค๋ฅ˜", { error: error.message }); res.status(500).json({ success: false, message: error.message }); } finally { client.release(); } } // ์ˆ˜์ฃผ ๋ฒˆํ˜ธ ์ƒ์„ฑ ํ•จ์ˆ˜ (์˜ˆ์‹œ - ์‹ค์ œ๋กœ๋Š” ์ฑ„๋ฒˆ ๊ทœ์น™ ์‹œ์Šคํ…œ ํ™œ์šฉ) async function generateOrderNumber(companyCode: string): Promise { const today = new Date(); const year = today.getFullYear().toString().slice(2); const month = String(today.getMonth() + 1).padStart(2, "0"); // ๋‹น์ผ ์ˆ˜์ฃผ ์นด์šดํŠธ ์กฐํšŒ const countQuery = ` SELECT COUNT(*) FROM order_mng_master WHERE company_code = $1 AND DATE(created_at) = CURRENT_DATE `; const result = await pool.query(countQuery, [companyCode]); const seq = parseInt(result.rows[0].count) + 1; return `ORD${year}${month}${String(seq).padStart(4, "0")}`; } ``` --- ## ๐ŸŽฏ Phase 3: ์ˆ˜์ฃผ ๋“ฑ๋ก ์ „์šฉ ์ปดํฌ๋„ŒํŠธ (1์ผ) ### 3.1 OrderRegistrationModal ์ปดํฌ๋„ŒํŠธ **ํŒŒ์ผ**: `frontend/components/order/OrderRegistrationModal.tsx` ```tsx "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 { EntitySearchInput } from "@/lib/registry/components/entity-search-input/EntitySearchInputComponent"; import { ModalRepeaterTable } from "@/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent"; 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("/api/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()}์›
)} {/* ๋ฉ”๋ชจ */}