From b97ca1a1c54f2bf52cad439edd95af0ae033a54f Mon Sep 17 00:00:00 2001 From: kmh Date: Mon, 30 Mar 2026 11:52:03 +0900 Subject: [PATCH] Enhance backend controllers, frontend pages, and V2 components - Fix department, receiving, shippingOrder, shippingPlan controllers - Update admin pages (company management, disk usage) - Improve sales/logistics pages (order, shipping, outbound, receiving) - Enhance V2 components (file-upload, split-panel-layout, table-list) - Add SmartSelect common component - Update DataGrid, FullscreenDialog common components - Add gitignore rules for personal pipeline tools Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 + .../src/controllers/departmentController.ts | 117 ++++++--- .../src/controllers/receivingController.ts | 204 ++++++++++++--- .../controllers/shippingOrderController.ts | 4 +- .../src/controllers/shippingPlanController.ts | 4 +- .../(main)/admin/userMng/companyList/page.tsx | 4 +- .../app/(main)/logistics/outbound/page.tsx | 95 ++++++- .../app/(main)/logistics/receiving/page.tsx | 106 ++++++-- .../(main)/master-data/department/page.tsx | 71 ++++-- frontend/app/(main)/sales/order/page.tsx | 234 +++++++++++------- frontend/app/(main)/sales/sales-item/page.tsx | 16 +- .../app/(main)/sales/shipping-order/page.tsx | 36 ++- frontend/components/admin/CompanyTable.tsx | 9 +- frontend/components/admin/CompanyToolbar.tsx | 2 +- .../components/admin/DiskUsageSummary.tsx | 6 +- frontend/components/common/DataGrid.tsx | 5 +- .../components/common/FullscreenDialog.tsx | 5 +- frontend/components/common/SmartSelect.tsx | 122 +++++++++ frontend/lib/api/receiving.ts | 27 +- .../v2-file-upload/FileUploadComponent.tsx | 91 ++++--- .../SplitPanelLayoutComponent.tsx | 143 +++++++---- .../v2-table-list/TableListComponent.tsx | 71 ++---- frontend/types/department.ts | 1 + 23 files changed, 1012 insertions(+), 365 deletions(-) create mode 100644 frontend/components/common/SmartSelect.tsx diff --git a/.gitignore b/.gitignore index 2644c641..082b5fa5 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,10 @@ mcp-task-queue/ .cursor/rules/multi-agent-reviewer.mdc .cursor/rules/multi-agent-knowledge.mdc +# MCP Agent Orchestrator (개인 파이프라인 도구) +mcp-agent-orchestrator/ +.mcp.json + # 파이프라인 회고록 (자동 생성) docs/retrospectives/ mes-architecture-guide.md diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts index 8238284f..c4218e71 100644 --- a/backend-node/src/controllers/departmentController.ts +++ b/backend-node/src/controllers/departmentController.ts @@ -67,16 +67,17 @@ export async function getDepartments(req: AuthenticatedRequest, res: Response): export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode } = req.params; + const companyCode = req.user!.companyCode; const department = await queryOne(` - SELECT + SELECT dept_code, dept_name, company_code, parent_dept_code FROM dept_info - WHERE dept_code = $1 - `, [deptCode]); + WHERE dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); if (!department) { res.status(404).json({ @@ -105,7 +106,7 @@ export async function getDepartment(req: AuthenticatedRequest, res: Response): P export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise { try { const { companyCode } = req.params; - const { dept_name, parent_dept_code } = req.body; + const { dept_name, parent_dept_code, dept_code: requestedDeptCode } = req.body; if (!dept_name || !dept_name.trim()) { res.status(400).json({ @@ -131,6 +132,30 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) return; } + // 프론트에서 채번 시스템으로 할당된 dept_code 필수 + if (!requestedDeptCode || !requestedDeptCode.trim()) { + res.status(400).json({ + success: false, + message: "부서코드가 필요합니다. 채번 규칙을 먼저 등록해주세요.", + }); + return; + } + + // 같은 회사 내 부서코드 중복 체크 + const codeDuplicate = await queryOne(` + SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2 + `, [requestedDeptCode.trim(), companyCode]); + + if (codeDuplicate) { + res.status(409).json({ + success: false, + message: `부서코드 "${requestedDeptCode}" 가 이미 존재합니다.`, + }); + return; + } + + const deptCode = requestedDeptCode.trim(); + // 회사 이름 조회 const company = await queryOne(` SELECT company_name FROM company_mng WHERE company_code = $1 @@ -138,16 +163,6 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) const companyName = company?.company_name || companyCode; - // 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...) - const codeResult = await queryOne(` - SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number - FROM dept_info - WHERE dept_code ~ '^DEPT_[0-9]+$' - `); - - const nextNumber = codeResult?.next_number || 1; - const deptCode = `DEPT_${nextNumber}`; - // 부서 생성 const result = await query(` INSERT INTO dept_info ( @@ -207,6 +222,7 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response) try { const { deptCode } = req.params; const { dept_name, parent_dept_code } = req.body; + const companyCode = req.user!.companyCode; if (!dept_name || !dept_name.trim()) { res.status(400).json({ @@ -218,12 +234,12 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response) const result = await query(` UPDATE dept_info - SET + SET dept_name = $1, parent_dept_code = $2 - WHERE dept_code = $3 + WHERE dept_code = $3 AND company_code = $4 RETURNING * - `, [dept_name.trim(), parent_dept_code || null, deptCode]); + `, [dept_name.trim(), parent_dept_code || null, deptCode, companyCode]); if (result.length === 0) { res.status(404).json({ @@ -270,13 +286,14 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response) export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode } = req.params; + const companyCode = req.user!.companyCode; // 하위 부서 확인 const hasChildren = await queryOne(` SELECT COUNT(*) as count FROM dept_info - WHERE parent_dept_code = $1 - `, [deptCode]); + WHERE parent_dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); if (parseInt(hasChildren?.count || "0") > 0) { res.status(400).json({ @@ -286,21 +303,22 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response) return; } - // 부서원 삭제 (부서 삭제 전에 먼저 삭제) + // 부서원 삭제 (부서 삭제 전에 먼저 삭제 — 해당 회사 부서만) const deletedMembers = await query(` DELETE FROM user_dept WHERE dept_code = $1 + AND dept_code IN (SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2) RETURNING user_id - `, [deptCode]); + `, [deptCode, companyCode]); const memberCount = deletedMembers.length; // 부서 삭제 const result = await query(` DELETE FROM dept_info - WHERE dept_code = $1 + WHERE dept_code = $1 AND company_code = $2 RETURNING dept_code, dept_name - `, [deptCode]); + `, [deptCode, companyCode]); if (result.length === 0) { res.status(404).json({ @@ -352,9 +370,10 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response) export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode } = req.params; + const companyCode = req.user!.companyCode; const members = await query(` - SELECT + SELECT u.user_id, u.user_name, u.email, @@ -367,9 +386,9 @@ export async function getDepartmentMembers(req: AuthenticatedRequest, res: Respo FROM user_dept ud JOIN user_info u ON ud.user_id = u.user_id JOIN dept_info d ON ud.dept_code = d.dept_code - WHERE ud.dept_code = $1 + WHERE ud.dept_code = $1 AND d.company_code = $2 ORDER BY ud.is_primary DESC, u.user_name - `, [deptCode]); + `, [deptCode, companyCode]); res.status(200).json({ success: true, @@ -438,6 +457,7 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon try { const { deptCode } = req.params; const { user_id } = req.body; + const companyCode = req.user!.companyCode; if (!user_id) { res.status(400).json({ @@ -447,12 +467,25 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon return; } + // 부서 소유권 확인 (해당 회사의 부서인지) + const dept = await queryOne(` + SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); + + if (!dept) { + res.status(403).json({ + success: false, + message: "해당 부서에 접근할 권한이 없습니다.", + }); + return; + } + // 사용자 존재 확인 const user = await queryOne(` SELECT user_id, user_name FROM user_info - WHERE user_id = $1 - `, [user_id]); + WHERE user_id = $1 AND company_code = $2 + `, [user_id, companyCode]); if (!user) { res.status(404).json({ @@ -512,6 +545,20 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode, userId } = req.params; + const companyCode = req.user!.companyCode; + + // 부서 소유권 확인 + const dept = await queryOne(` + SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); + + if (!dept) { + res.status(403).json({ + success: false, + message: "해당 부서에 접근할 권한이 없습니다.", + }); + return; + } const result = await query(` DELETE FROM user_dept @@ -548,6 +595,20 @@ export async function removeDepartmentMember(req: AuthenticatedRequest, res: Res export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode, userId } = req.params; + const companyCode = req.user!.companyCode; + + // 부서 소유권 확인 + const dept = await queryOne(` + SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); + + if (!dept) { + res.status(403).json({ + success: false, + message: "해당 부서에 접근할 권한이 없습니다.", + }); + return; + } // 다른 부서의 주 부서 해제 await query(` diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index f0b6358b..79188f7f 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -2,7 +2,7 @@ * 입고관리 컨트롤러 * * 입고유형별 소스 테이블: - * - 구매입고 → purchase_order_mng (발주) + * - 구매입고 → purchase_order_mng (발주 헤더) + purchase_detail (발주 디테일) * - 반품입고 → shipment_instruction + shipment_instruction_detail (출하) * - 기타입고 → item_info (품목) */ @@ -228,6 +228,39 @@ export async function create(req: AuthenticatedRequest, res: Response) { [item.inbound_qty || 0, item.source_id, companyCode] ); } + + // 구매입고인 경우 purchase_detail 기반 발주의 헤더 상태 업데이트 + if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") { + // 해당 디테일의 발주번호 조회 + const detailInfo = await client.query( + `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, + [item.source_id, companyCode] + ); + if (detailInfo.rows.length > 0) { + const purchaseNo = detailInfo.rows[0].purchase_no; + // 해당 발주의 모든 디테일 잔량 확인 + const unreceived = await client.query( + `SELECT pd.id + FROM purchase_detail pd + LEFT JOIN ( + SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received + FROM inbound_mng + WHERE source_table = 'purchase_detail' AND company_code = $1 + GROUP BY source_id + ) r ON r.source_id = pd.id + WHERE pd.purchase_no = $2 AND pd.company_code = $1 + AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0 + LIMIT 1`, + [companyCode, purchaseNo] + ); + const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고'; + await client.query( + `UPDATE purchase_order_mng SET status = $1, updated_date = NOW() + WHERE purchase_no = $2 AND company_code = $3`, + [newStatus, purchaseNo, companyCode] + ); + } + } } await client.query("COMMIT"); @@ -332,50 +365,115 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response) } } -// 구매입고용: 발주 데이터 조회 (미입고분) +// 구매입고용: 발주 데이터 조회 (미입고분) - 신규 헤더-디테일 구조 + 레거시 단일 테이블 UNION ALL export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const { keyword } = req.query; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; - const conditions: string[] = ["company_code = $1"]; const params: any[] = [companyCode]; let paramIdx = 2; - // 잔량이 있는 것만 조회 - conditions.push( - `COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0` - ); - conditions.push(`status NOT IN ('입고완료', '취소')`); - + let keywordConditionDetail = ""; + let keywordConditionLegacy = ""; if (keyword) { - conditions.push( - `(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})` - ); + keywordConditionDetail = `AND (pd.purchase_no ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_name, ''), ii.item_name) ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_code, ''), ii.item_number) ILIKE $${paramIdx} OR COALESCE(pd.supplier_name, po.supplier_name) ILIKE $${paramIdx})`; + keywordConditionLegacy = `AND (po.purchase_no ILIKE $${paramIdx} OR po.item_name ILIKE $${paramIdx} OR po.item_code ILIKE $${paramIdx} OR po.supplier_name ILIKE $${paramIdx})`; params.push(`%${keyword}%`); paramIdx++; } + const baseQuery = ` + WITH detail_received AS ( + SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received + FROM inbound_mng + WHERE source_table = 'purchase_detail' AND company_code = $1 + GROUP BY source_id + ), + combined AS ( + -- 디테일 기반 발주 데이터 (신규 헤더-디테일 구조, 헤더 없는 디테일도 포함) + SELECT + pd.id, + COALESCE(po.purchase_no, pd.purchase_no) AS purchase_no, + po.order_date, + COALESCE(pd.supplier_code, po.supplier_code) AS supplier_code, + COALESCE(pd.supplier_name, po.supplier_name) AS supplier_name, + COALESCE(NULLIF(pd.item_code, ''), ii.item_number) AS item_code, + COALESCE(NULLIF(pd.item_name, ''), ii.item_name) AS item_name, + COALESCE(NULLIF(pd.spec, ''), ii.size) AS spec, + COALESCE(NULLIF(pd.material, ''), ii.material) AS material, + COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) AS order_qty, + COALESCE(dr.total_received, 0) AS received_qty, + COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) AS remain_qty, + COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price, + COALESCE(po.status, '') AS status, + COALESCE(pd.due_date, po.due_date) AS due_date, + 'purchase_detail' AS source_table + FROM purchase_detail pd + LEFT JOIN purchase_order_mng po + ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code + LEFT JOIN item_info ii ON pd.item_id = ii.id + LEFT JOIN detail_received dr ON dr.source_id = pd.id + WHERE pd.company_code = $1 + AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) > 0 + AND COALESCE(pd.approval_status, '') NOT IN ('반려') + AND COALESCE(po.status, '') NOT IN ('입고완료', '취소') + ${keywordConditionDetail} + + UNION ALL + + -- 레거시 단일 테이블 데이터 (purchase_detail에 없는 발주) + SELECT + po.id, + po.purchase_no, + po.order_date, + po.supplier_code, + po.supplier_name, + po.item_code, + po.item_name, + po.spec, + po.material, + COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) AS order_qty, + COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) AS received_qty, + COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric), + COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) + - COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) + ) AS remain_qty, + COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price, + po.status, + po.due_date, + 'purchase_order_mng' AS source_table + FROM purchase_order_mng po + WHERE po.company_code = $1 + AND NOT EXISTS ( + SELECT 1 FROM purchase_detail pd + WHERE pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code + ) + AND COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric), + COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) + - COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) + ) > 0 + AND po.status NOT IN ('입고완료', '취소') + ${keywordConditionLegacy} + )`; + const pool = getPool(); - const result = await pool.query( - `SELECT - id, purchase_no, order_date, supplier_code, supplier_name, - item_code, item_name, spec, material, - COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty, - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty, - COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), - COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - ) AS remain_qty, - COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price, - status, due_date - FROM purchase_order_mng - WHERE ${conditions.join(" AND ")} - ORDER BY order_date DESC, purchase_no`, + + const countResult = await pool.query( + `${baseQuery} SELECT COUNT(*) AS total FROM combined`, + params + ); + const totalCount = parseInt(countResult.rows[0].total, 10); + + const dataResult = await pool.query( + `${baseQuery} SELECT * FROM combined ORDER BY order_date DESC, purchase_no LIMIT ${limit} OFFSET ${offset}`, params ); - return res.json({ success: true, data: result.rows }); + return res.json({ success: true, data: dataResult.rows, totalCount }); } catch (error: any) { logger.error("발주 데이터 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); @@ -386,7 +484,10 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response export async function getShipments(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const { keyword } = req.query; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; const conditions: string[] = ["si.company_code = $1"]; const params: any[] = [companyCode]; @@ -400,8 +501,20 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { paramIdx++; } + const whereClause = conditions.join(" AND "); const pool = getPool(); - const result = await pool.query( + + const countResult = await pool.query( + `SELECT COUNT(*) AS total + FROM shipment_instruction si + JOIN shipment_instruction_detail sid + ON si.id = sid.instruction_id AND si.company_code = sid.company_code + WHERE ${whereClause}`, + params + ); + const totalCount = parseInt(countResult.rows[0].total, 10); + + const dataResult = await pool.query( `SELECT sid.id AS detail_id, si.id AS instruction_id, @@ -420,12 +533,13 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { JOIN shipment_instruction_detail sid ON si.id = sid.instruction_id AND si.company_code = sid.company_code - WHERE ${conditions.join(" AND ")} - ORDER BY si.instruction_date DESC, si.instruction_no`, + WHERE ${whereClause} + ORDER BY si.instruction_date DESC, si.instruction_no + LIMIT ${limit} OFFSET ${offset}`, params ); - return res.json({ success: true, data: result.rows }); + return res.json({ success: true, data: dataResult.rows, totalCount }); } catch (error: any) { logger.error("출하 데이터 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); @@ -436,7 +550,10 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { export async function getItems(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const { keyword } = req.query; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; const conditions: string[] = ["company_code = $1"]; const params: any[] = [companyCode]; @@ -450,18 +567,27 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { paramIdx++; } + const whereClause = conditions.join(" AND "); const pool = getPool(); - const result = await pool.query( + + const countResult = await pool.query( + `SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, + params + ); + const totalCount = parseInt(countResult.rows[0].total, 10); + + const dataResult = await pool.query( `SELECT id, item_number, item_name, size AS spec, material, unit, COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price FROM item_info - WHERE ${conditions.join(" AND ")} - ORDER BY item_name`, + WHERE ${whereClause} + ORDER BY item_name + LIMIT ${limit} OFFSET ${offset}`, params ); - return res.json({ success: true, data: result.rows }); + return res.json({ success: true, data: dataResult.rows, totalCount }); } catch (error: any) { logger.error("품목 데이터 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); diff --git a/backend-node/src/controllers/shippingOrderController.ts b/backend-node/src/controllers/shippingOrderController.ts index d7795fcf..6044c4b9 100644 --- a/backend-node/src/controllers/shippingOrderController.ts +++ b/backend-node/src/controllers/shippingOrderController.ts @@ -338,7 +338,7 @@ export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Resp LIMIT 1 ) i ON true LEFT JOIN customer_mng c - ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code + ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code AND sp.company_code = c.company_code WHERE ${whereClause} `; @@ -354,7 +354,7 @@ export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Resp COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name, COALESCE(d.spec, m.spec, '') AS spec, COALESCE(m.material, '') AS material, - COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, + COALESCE(c.customer_name, '') AS customer_name, COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, sp.detail_id, sp.sales_order_id ${fromClause} diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index b56c3617..64f63f76 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -215,7 +215,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name, COALESCE(d.spec, m.spec, '') AS spec, COALESCE(m.material, '') AS material, - COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, + COALESCE(c.customer_name, '') AS customer_name, COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, COALESCE(d.due_date, m.due_date::text, '') AS due_date, COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, @@ -232,7 +232,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { LIMIT 1 ) i ON true LEFT JOIN customer_mng c - ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code + ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code AND sp.company_code = c.company_code ${whereClause} ORDER BY sp.created_date DESC diff --git a/frontend/app/(main)/admin/userMng/companyList/page.tsx b/frontend/app/(main)/admin/userMng/companyList/page.tsx index a36cd9c3..8c8bd617 100644 --- a/frontend/app/(main)/admin/userMng/companyList/page.tsx +++ b/frontend/app/(main)/admin/userMng/companyList/page.tsx @@ -52,8 +52,8 @@ export default function CompanyPage() { } = useCompanyManagement(); return ( -
-
+
+
{/* 페이지 헤더 */}

회사 관리

diff --git a/frontend/app/(main)/logistics/outbound/page.tsx b/frontend/app/(main)/logistics/outbound/page.tsx index 57ea6455..b834b76a 100644 --- a/frontend/app/(main)/logistics/outbound/page.tsx +++ b/frontend/app/(main)/logistics/outbound/page.tsx @@ -37,6 +37,9 @@ import { X, Save, ChevronRight, + ChevronLeft, + ChevronsLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -131,6 +134,10 @@ export default function OutboundPage() { const [items, setItems] = useState([]); const [warehouses, setWarehouses] = useState([]); + // 소스 데이터 페이징 (클라이언트 사이드) + const [sourcePage, setSourcePage] = useState(1); + const [sourcePageSize, setSourcePageSize] = useState(20); + // 날짜 초기화 useEffect(() => { const today = new Date(); @@ -261,13 +268,44 @@ export default function OutboundPage() { }; const searchSourceData = useCallback(async () => { + setSourcePage(1); await loadSourceData(modalOutboundType, sourceKeyword || undefined); }, [modalOutboundType, sourceKeyword, loadSourceData]); + // 현재 출고유형에 따른 전체 소스 데이터 + const allSourceData = useMemo(() => { + if (modalOutboundType === "판매출고") return shipmentInstructions; + if (modalOutboundType === "반품출고") return purchaseOrders; + return items; + }, [modalOutboundType, shipmentInstructions, purchaseOrders, items]); + + const sourceTotalCount = allSourceData.length; + const sourceTotalPages = Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize)); + + // 현재 페이지에 해당하는 slice + const pagedShipmentInstructions = useMemo(() => { + if (modalOutboundType !== "판매출고") return []; + const start = (sourcePage - 1) * sourcePageSize; + return shipmentInstructions.slice(start, start + sourcePageSize); + }, [modalOutboundType, shipmentInstructions, sourcePage, sourcePageSize]); + + const pagedPurchaseOrders = useMemo(() => { + if (modalOutboundType !== "반품출고") return []; + const start = (sourcePage - 1) * sourcePageSize; + return purchaseOrders.slice(start, start + sourcePageSize); + }, [modalOutboundType, purchaseOrders, sourcePage, sourcePageSize]); + + const pagedItems = useMemo(() => { + if (modalOutboundType !== "기타출고") return []; + const start = (sourcePage - 1) * sourcePageSize; + return items.slice(start, start + sourcePageSize); + }, [modalOutboundType, items, sourcePage, sourcePageSize]); + const handleOutboundTypeChange = useCallback( (type: string) => { setModalOutboundType(type); setSourceKeyword(""); + setSourcePage(1); setShipmentInstructions([]); setPurchaseOrders([]); setItems([]); @@ -686,6 +724,7 @@ export default function OutboundPage() { defaultMaxWidth="sm:max-w-[1600px]" defaultWidth="w-[95vw]" className="h-[90vh] p-0" + contentClassName="overflow-hidden flex flex-col" footer={
@@ -774,43 +813,87 @@ export default function OutboundPage() {
-
-

+
+

{modalOutboundType === "판매출고" ? "미출고 출하지시 목록" : modalOutboundType === "반품출고" ? "입고된 발주 목록" : "품목 목록"}

+ {sourceTotalCount > 0 && ( + 총 {sourceTotalCount}건 + )} +
+
{sourceLoading ? (
) : modalOutboundType === "판매출고" ? ( s.key)} /> ) : modalOutboundType === "반품출고" ? ( s.key)} /> ) : ( s.key)} /> )}
+ + {/* 페이징 바 */} + {sourceTotalCount > 0 && ( +
+
+ 표시: + { + const v = parseInt(e.target.value, 10); + if (v > 0) { setSourcePageSize(v); setSourcePage(1); } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> +
+
+ + + {sourcePage} / {sourceTotalPages} + + +
+
+ )}

- + e.stopPropagation()} /> {/* 우측: 출고 정보 + 선택 품목 */} diff --git a/frontend/app/(main)/logistics/receiving/page.tsx b/frontend/app/(main)/logistics/receiving/page.tsx index a5de8c9d..6e493a5c 100644 --- a/frontend/app/(main)/logistics/receiving/page.tsx +++ b/frontend/app/(main)/logistics/receiving/page.tsx @@ -37,6 +37,9 @@ import { X, Save, ChevronRight, + ChevronLeft, + ChevronsLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -132,6 +135,11 @@ export default function ReceivingPage() { const [items, setItems] = useState([]); const [warehouses, setWarehouses] = useState([]); + // 소스 데이터 페이징 + const [sourcePage, setSourcePage] = useState(1); + const [sourcePageSize, setSourcePageSize] = useState(20); + const [sourceTotalCount, setSourceTotalCount] = useState(0); + // 날짜 초기화 useEffect(() => { const today = new Date(); @@ -214,18 +222,32 @@ export default function ReceivingPage() { // 소스 데이터 로드 함수 const loadSourceData = useCallback( - async (type: string, keyword?: string) => { + async (type: string, keyword?: string, pageOverride?: number) => { setSourceLoading(true); try { + const params = { + keyword: keyword || undefined, + page: pageOverride ?? sourcePage, + pageSize: sourcePageSize, + }; if (type === "구매입고") { - const res = await getPurchaseOrderSources(keyword || undefined); - if (res.success) setPurchaseOrders(res.data); + const res = await getPurchaseOrderSources(params); + if (res.success) { + setPurchaseOrders(res.data); + setSourceTotalCount(res.totalCount || 0); + } } else if (type === "반품입고") { - const res = await getShipmentSources(keyword || undefined); - if (res.success) setShipments(res.data); + const res = await getShipmentSources(params); + if (res.success) { + setShipments(res.data); + setSourceTotalCount(res.totalCount || 0); + } } else { - const res = await getItemSources(keyword || undefined); - if (res.success) setItems(res.data); + const res = await getItemSources(params); + if (res.success) { + setItems(res.data); + setSourceTotalCount(res.totalCount || 0); + } } } catch { // ignore @@ -233,7 +255,7 @@ export default function ReceivingPage() { setSourceLoading(false); } }, - [] + [sourcePage, sourcePageSize] ); const openRegisterModal = async () => { @@ -250,13 +272,15 @@ export default function ReceivingPage() { setPurchaseOrders([]); setShipments([]); setItems([]); + setSourcePage(1); + setSourceTotalCount(0); setIsModalOpen(true); // 입고번호 생성 + 발주 데이터 동시 로드 try { const [numRes] = await Promise.all([ generateReceivingNumber(), - loadSourceData(defaultType), + loadSourceData(defaultType, undefined, 1), ]); if (numRes.success) setModalInboundNo(numRes.data); } catch { @@ -266,7 +290,8 @@ export default function ReceivingPage() { // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { - await loadSourceData(modalInboundType, sourceKeyword || undefined); + setSourcePage(1); + await loadSourceData(modalInboundType, sourceKeyword || undefined, 1); }, [modalInboundType, sourceKeyword, loadSourceData]); // 입고유형 변경 시 소스 데이터 자동 리로드 @@ -278,7 +303,9 @@ export default function ReceivingPage() { setShipments([]); setItems([]); setSelectedItems([]); - loadSourceData(type); + setSourcePage(1); + setSourceTotalCount(0); + loadSourceData(type, undefined, 1); }, [loadSourceData] ); @@ -303,7 +330,7 @@ export default function ReceivingPage() { inbound_qty: po.remain_qty, unit_price: po.unit_price, total_amount: po.remain_qty * po.unit_price, - source_table: "purchase_order_mng", + source_table: po.source_table || "purchase_order_mng", source_id: po.id, }, ]); @@ -694,6 +721,7 @@ export default function ReceivingPage() { defaultMaxWidth="sm:max-w-[1600px]" defaultWidth="w-[95vw]" className="h-[90vh] p-0" + contentClassName="overflow-hidden flex flex-col" footer={
@@ -817,10 +845,56 @@ export default function ReceivingPage() { /> )}
+ + {/* 페이징 */} + {sourceTotalCount > 0 && ( +
+
+ 표시: + { + const v = parseInt(e.target.value, 10); + if (v > 0) { + setSourcePageSize(v); + setSourcePage(1); + loadSourceData(modalInboundType, sourceKeyword || undefined, 1); + } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> + + 총 {sourceTotalCount}건 + +
+
+ + + {sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))} + + +
+
+ )}
- + e.stopPropagation()} /> {/* 우측: 입고 정보 + 선택 품목 */} @@ -1030,7 +1104,7 @@ function SourcePurchaseOrderTable({ return ( - + 발주번호 @@ -1109,7 +1183,7 @@ function SourceShipmentTable({ return (
- + 출하번호 @@ -1186,7 +1260,7 @@ function SourceItemTable({ return (
- + 품목 diff --git a/frontend/app/(main)/master-data/department/page.tsx b/frontend/app/(main)/master-data/department/page.tsx index 4e943810..300eedd6 100644 --- a/frontend/app/(main)/master-data/department/page.tsx +++ b/frontend/app/(main)/master-data/department/page.tsx @@ -25,6 +25,8 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import * as departmentAPI from "@/lib/api/department"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -78,6 +80,10 @@ export default function DepartmentPage() { const [deptForm, setDeptForm] = useState>({}); const [saving, setSaving] = useState(false); + // 채번 시스템 + const [numberingRuleId, setNumberingRuleId] = useState(null); + const [previewCode, setPreviewCode] = useState(null); + // 사원 모달 const [userModalOpen, setUserModalOpen] = useState(false); const [userEditMode, setUserEditMode] = useState(false); @@ -112,7 +118,6 @@ export default function DepartmentPage() { setDepts(data); setDeptCount(res.data?.data?.total || data.length); } catch (err) { - console.error("부서 조회 실패:", err); toast.error("부서 목록을 불러오는데 실패했습니다."); } finally { setDeptLoading(false); @@ -144,10 +149,28 @@ export default function DepartmentPage() { useEffect(() => { fetchMembers(); }, [fetchMembers]); // 부서 등록 - const openDeptRegister = () => { + const openDeptRegister = async () => { setDeptForm({}); setDeptEditMode(false); + setPreviewCode(null); + setNumberingRuleId(null); setDeptModalOpen(true); + + // 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출 + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`); + const ruleData = ruleRes.data; + if (ruleData?.success && ruleData?.data?.ruleId) { + const ruleId = ruleData.data.ruleId; + setNumberingRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setPreviewCode(previewRes.data.generatedCode); + } + } + } catch { + // 채번 규칙 없으면 무시 + } }; const openDeptEdit = () => { @@ -159,20 +182,40 @@ export default function DepartmentPage() { const handleDeptSave = async () => { if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; } + const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null; setSaving(true); try { if (deptEditMode && deptForm.dept_code) { - await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, { - originalData: { dept_code: deptForm.dept_code }, - updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null }, + const response = await departmentAPI.updateDepartment(deptForm.dept_code, { + dept_name: deptForm.dept_name, + parent_dept_code: parentCode, }); + if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; } toast.success("수정되었습니다."); } else { - await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { - dept_code: deptForm.dept_code || "", + const companyCode = user?.companyCode || ""; + + // 채번 규칙이 있으면 allocate로 실제 코드 할당 + let allocatedCode: string | undefined; + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + allocatedCode = allocRes.data.generatedCode; + } else { + toast.error("채번 코드 할당에 실패했습니다."); + return; + } + } + + const response = await departmentAPI.createDepartment(companyCode, { dept_name: deptForm.dept_name, - parent_dept_code: deptForm.parent_dept_code || null, + parent_dept_code: parentCode, + dept_code: allocatedCode, }); + if (!response.success) { + toast.error((response as any).error || "등록에 실패했습니다."); + return; + } toast.success("등록되었습니다."); } setDeptModalOpen(false); @@ -193,10 +236,9 @@ export default function DepartmentPage() { }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, { - data: [{ dept_code: selectedDeptCode }], - }); - toast.success("삭제되었습니다."); + const response = await departmentAPI.deleteDepartment(selectedDeptCode); + if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; } + toast.success(response.message || "삭제되었습니다."); setSelectedDeptId(null); fetchDepts(); } catch { toast.error("삭제에 실패했습니다."); } @@ -373,8 +415,9 @@ export default function DepartmentPage() {
- setDeptForm((p) => ({ ...p, dept_code: e.target.value }))} - placeholder="부서코드" className="h-9" disabled={deptEditMode} /> +
diff --git a/frontend/app/(main)/sales/order/page.tsx b/frontend/app/(main)/sales/order/page.tsx index 283f4362..855470ee 100644 --- a/frontend/app/(main)/sales/order/page.tsx +++ b/frontend/app/(main)/sales/order/page.tsx @@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label"; import { Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download, ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -28,6 +29,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; +import { SmartSelect } from "@/components/common/SmartSelect"; const DETAIL_TABLE = "sales_order_detail"; @@ -46,7 +48,7 @@ const MASTER_TABLE = "sales_order_mng"; const GRID_COLUMNS: DataGridColumn[] = [ { key: "order_no", label: "수주번호", width: "w-[120px]" }, { key: "part_code", label: "품번", width: "w-[120px]", editable: true }, - { key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true }, + { key: "part_name", label: "품명", width: "w-[150px]", editable: true }, { key: "spec", label: "규격", width: "w-[120px]", editable: true }, { key: "unit", label: "단위", width: "w-[70px]", editable: true }, { key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" }, @@ -54,6 +56,7 @@ const GRID_COLUMNS: DataGridColumn[] = [ { key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" }, { key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" }, { key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" }, + { key: "currency_code", label: "통화", width: "w-[70px]" }, { key: "due_date", label: "납기일", width: "w-[110px]" }, { key: "memo", label: "메모", width: "w-[100px]", editable: true }, ]; @@ -85,7 +88,12 @@ export default function SalesOrderPage() { const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); - const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemPage, setItemPage] = useState(1); + const [itemPageSize, setItemPageSize] = useState(20); + const [itemTotalPages, setItemTotalPages] = useState(0); + const [itemTotal, setItemTotal] = useState(0); + const [itemPageInput, setItemPageInput] = useState("1"); // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -221,6 +229,23 @@ export default function SalesOrderPage() { }); const rows = res.data?.data?.data || res.data?.data?.rows || []; + // order_no → sales_order_mng 조인 (memo 등 마스터 필드 보강) + const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))]; + let masterMap: Record = {}; + if (orderNos.length > 0) { + try { + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: orderNos.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] }, + autoFilter: true, + }); + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + for (const m of masters) { + masterMap[m.order_no] = m; + } + } catch { /* skip */ } + } + // part_code → item_info 조인 (품명/규격이 비어있는 경우 보강) const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))]; let itemMap: Record = {}; @@ -247,19 +272,20 @@ export default function SalesOrderPage() { }; const data = rows.map((row: any) => { const item = itemMap[row.part_code]; + const master = masterMap[row.order_no]; const rawUnit = row.unit || item?.unit || ""; return { ...row, part_name: row.part_name || item?.item_name || "", spec: row.spec || item?.size || "", unit: resolveLabel("item_unit", rawUnit) || rawUnit, + memo: row.memo || master?.memo || "", }; }); setOrders(data); setTotalCount(res.data?.data?.total || data.length); } catch (err) { - console.error("수주 조회 실패:", err); toast.error("수주 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); @@ -330,7 +356,6 @@ export default function SalesOrderPage() { setIsEditMode(true); setIsModalOpen(true); } catch (err) { - console.error("수주 상세 조회 실패:", err); toast.error("수주 정보를 불러오는데 실패했습니다."); } }; @@ -377,7 +402,6 @@ export default function SalesOrderPage() { setCheckedIds([]); fetchOrders(); } catch (err) { - console.error("삭제 실패:", err); toast.error("삭제에 실패했습니다."); } }; @@ -433,7 +457,6 @@ export default function SalesOrderPage() { setIsModalOpen(false); fetchOrders(); } catch (err: any) { - console.error("저장 실패:", err); toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); @@ -441,7 +464,9 @@ export default function SalesOrderPage() { }; // 품목 검색 (리피터에서 추가) - const searchItems = async () => { + const searchItems = async (page?: number, size?: number) => { + const p = page ?? itemPage; + const s = size ?? itemPageSize; setItemSearchLoading(true); try { const filters: any[] = []; @@ -449,18 +474,45 @@ export default function SalesOrderPage() { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 50, + page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []); + const resData = res.data?.data; + setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || 0); + setItemTotalPages(resData?.totalPages || Math.ceil((resData?.total || 0) / s)); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; + const handleItemPageChange = (newPage: number) => { + if (newPage < 1 || newPage > itemTotalPages) return; + setItemPage(newPage); + setItemPageInput(String(newPage)); + searchItems(newPage); + }; + + const commitItemPageInput = () => { + const parsed = parseInt(itemPageInput, 10); + if (isNaN(parsed) || itemPageInput.trim() === "") { + setItemPageInput(String(itemPage)); + return; + } + const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1)); + if (clamped !== itemPage) handleItemPageChange(clamped); + setItemPageInput(String(clamped)); + }; + + const triggerNewSearch = () => { + setItemPage(1); + setItemPageInput("1"); + searchItems(1); + }; + const addSelectedItemsToDetail = async () => { - const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id)); + const selected = Array.from(itemSelectedMap.values()); if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } // 단가방식에 따라 단가 조회 @@ -492,7 +544,7 @@ export default function SalesOrderPage() { if (price) customerPriceMap[m.item_id] = String(price); } } catch (err) { - console.error("거래처별 단가 조회 실패:", err); + // 단가 조회 실패 시 무시 } } @@ -516,15 +568,17 @@ export default function SalesOrderPage() { material: getCategoryLabel("item_material", item.material) || item.material || "", unit: getCategoryLabel("item_unit", item.unit) || item.unit || "", qty: "", + standard_price: item.standard_price || "", unit_price: unitPrice, amount: "", + currency_code: item.currency_code || "", due_date: "", }; }); setDetailRows((prev) => [...prev, ...newRows]); toast.success(`${selected.length}개 품목이 추가되었습니다.`); - setItemCheckedIds(new Set()); + setItemSelectedMap(new Map()); setItemSelectOpen(false); }; @@ -655,30 +709,15 @@ export default function SalesOrderPage() {
- + setMasterForm((p) => ({ ...p, sell_mode: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, input_mode: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, price_mode: v }))} placeholder="선택" />
@@ -687,39 +726,23 @@ export default function SalesOrderPage() {
- + { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }} placeholder="거래처 선택" />
- + setMasterForm((p) => ({ ...p, manager_id: v }))} placeholder="담당자 선택" />
{deliveryOptions.length > 0 ? ( - + }} placeholder="납품처 선택" /> ) : ( setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} /> @@ -738,21 +761,11 @@ export default function SalesOrderPage() {
- + setMasterForm((p) => ({ ...p, incoterms: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, payment_term: v }))} placeholder="선택" />
@@ -781,28 +794,30 @@ export default function SalesOrderPage() {
수주 품목 -
-
+
품번 - 품명 + 품명 규격 단위 - 수량 - 단가 - 금액 - 납기일 + 기준단가 + 수량 + 단가 + 금액 + 통화 + 납기일 {detailRows.length === 0 ? ( - 품목을 추가해주세요 + 품목을 추가해주세요 ) : detailRows.map((row, idx) => ( @@ -814,6 +829,7 @@ export default function SalesOrderPage() { {row.part_name} {row.spec} {row.unit} + {row.standard_price ? Number(row.standard_price).toLocaleString() : ""} updateDetailRow(idx, "qty", parseNumber(e.target.value))} className="h-8 text-sm text-right" /> @@ -823,6 +839,10 @@ export default function SalesOrderPage() { className="h-8 text-sm text-right" /> {row.amount ? Number(row.amount).toLocaleString() : ""} + + updateDetailRow(idx, "currency_code", e.target.value)} + className="h-8 text-sm" /> + updateDetailRow(idx, "due_date", v)} placeholder="납기일" /> @@ -851,22 +871,26 @@ export default function SalesOrderPage() {
setItemSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && searchItems()} + onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()} className="h-9 flex-1" /> -
-
+
0 && itemCheckedIds.size === itemSearchResults.length} + checked={itemSearchResults.length > 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))} onChange={(e) => { - if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id))); - else setItemCheckedIds(new Set()); + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i)); + else itemSearchResults.forEach((i) => next.delete(i.id)); + return next; + }); }} /> 품목코드 @@ -880,14 +904,14 @@ export default function SalesOrderPage() { {itemSearchResults.length === 0 ? ( 검색 결과가 없습니다 ) : itemSearchResults.map((item) => ( - setItemCheckedIds((prev) => { - const next = new Set(prev); - if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item); return next; })}> - + {item.item_number} {item.item_name} @@ -899,13 +923,53 @@ export default function SalesOrderPage() {
+
+
+ 표시: + { + const v = Math.min(200, Math.max(1, Number(e.target.value) || 20)); + setItemPageSize(v); + setItemPage(1); + setItemPageInput("1"); + searchItems(1, v); + }} + className="h-7 w-14 rounded-md border px-1 text-center text-xs" /> +
+
+ + + setItemPageInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }} + onBlur={commitItemPageInput} + onFocus={(e) => e.target.select()} + className="h-7 w-10 rounded-md border px-1 text-center text-xs" /> + / {itemTotalPages || 1} + + +
+ 총 {itemTotal}건 +
- {itemCheckedIds.size}개 선택됨 + {itemSelectedMap.size}개 선택됨
- - +
diff --git a/frontend/app/(main)/sales/sales-item/page.tsx b/frontend/app/(main)/sales/sales-item/page.tsx index a5097b42..5da2b3b5 100644 --- a/frontend/app/(main)/sales/sales-item/page.tsx +++ b/frontend/app/(main)/sales/sales-item/page.tsx @@ -81,7 +81,7 @@ export default function SalesItemPage() { const [customerLoading, setCustomerLoading] = useState(false); // 카테고리 - const [categoryOptions, setCategoryOptions] = useState>({}); + const [categoryOptions, setCategoryOptions] = useState>({}); // 거래처 추가 모달 const [custSelectOpen, setCustSelectOpen] = useState(false); @@ -125,11 +125,11 @@ export default function SalesItemPage() { // 카테고리 로드 useEffect(() => { const load = async () => { - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => { + const result: { code: string; label: string; isDefault?: boolean }[] = []; for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); + result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); if (v.children?.length) result.push(...flatten(v.children)); } return result; @@ -164,7 +164,11 @@ export default function SalesItemPage() { const fetchItems = useCallback(async () => { setItemLoading(true); try { - const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + + // 판매품목 division 필터 (다중값 컬럼이므로 contains로 매칭) + filters.push({ columnName: "division", operator: "contains", value: "CAT_DIV_SALES" }); + const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, diff --git a/frontend/app/(main)/sales/shipping-order/page.tsx b/frontend/app/(main)/sales/shipping-order/page.tsx index 9ffb45bd..5ff26159 100644 --- a/frontend/app/(main)/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/sales/shipping-order/page.tsx @@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react"; +import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { @@ -117,7 +117,7 @@ export default function ShippingOrderPage() { const [sourceLoading, setSourceLoading] = useState(false); const [selectedItems, setSelectedItems] = useState([]); const [sourcePage, setSourcePage] = useState(1); - const [sourcePageSize] = useState(20); + const [sourcePageSize, setSourcePageSize] = useState(20); const [sourceTotalCount, setSourceTotalCount] = useState(0); // 텍스트 입력 debounce (500ms) @@ -592,6 +592,8 @@ export default function ShippingOrderPage() { description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."} defaultMaxWidth="max-w-[90vw]" defaultWidth="w-[1400px]" + className="h-[90vh]" + contentClassName="overflow-hidden flex flex-col" footer={ <> @@ -694,10 +696,28 @@ export default function ShippingOrderPage() { {/* 페이징 */} {sourceTotalCount > 0 && (
- - 총 {sourceTotalCount}건 중 {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}건 - +
+ 표시: + { + const v = parseInt(e.target.value, 10); + if (v > 0) { setSourcePageSize(v); setSourcePage(1); fetchSourceData(1); } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> + + 총 {sourceTotalCount}건 + +
+ +
)}
- + e.stopPropagation()} /> {/* 오른쪽: 폼 */} diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index e20fb806..0855fef9 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -54,7 +54,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company { key: "company_code", label: "회사코드", - width: "150px", + width: "12%", render: (value) => {value}, }, { @@ -65,11 +65,12 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company { key: "writer", label: "등록자", - width: "200px", + width: "15%", }, { key: "diskUsage", label: "디스크 사용량", + width: "15%", hideOnMobile: true, render: (_value, row) => formatDiskUsage(row), }, @@ -99,7 +100,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company cardSubtitle={(c) => {c.company_code}} cardFields={cardFields} actionsLabel="작업" - actionsWidth="180px" + actionsWidth="12%" + tableContainerClassName="!block" + cardContainerClassName="!hidden" renderActions={(company) => ( <> diff --git a/frontend/components/admin/DiskUsageSummary.tsx b/frontend/components/admin/DiskUsageSummary.tsx index 55bc1918..44676d65 100644 --- a/frontend/components/admin/DiskUsageSummary.tsx +++ b/frontend/components/admin/DiskUsageSummary.tsx @@ -15,7 +15,7 @@ interface DiskUsageSummaryProps { export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) { if (!diskUsageInfo) { return ( -
+

디스크 사용량

@@ -46,7 +46,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs const lastCheckedDate = new Date(lastChecked); return ( -
+

디스크 사용량 현황

@@ -64,7 +64,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
-
+
{/* 총 회사 수 */}
diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index 1806aa94..f122a318 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -108,11 +108,11 @@ function SortableHeaderCell({ style={style} className={cn(col.width, col.minWidth, "select-none relative")} > -
+
{ e.stopPropagation(); if (col.sortable !== false) onSort(col.key); @@ -366,7 +366,6 @@ export function DataGrid({ row[colKey] = editValue; toast.success("저장됨"); } catch (err) { - console.error("셀 저장 실패:", err); toast.error("저장에 실패했습니다."); setEditingCell(null); return; diff --git a/frontend/components/common/FullscreenDialog.tsx b/frontend/components/common/FullscreenDialog.tsx index 23189fbc..c723f2c5 100644 --- a/frontend/components/common/FullscreenDialog.tsx +++ b/frontend/components/common/FullscreenDialog.tsx @@ -31,6 +31,8 @@ interface FullscreenDialogProps { /** 기본 모달 너비 (기본: "w-[95vw]") */ defaultWidth?: string; className?: string; + /** children wrapper에 추가할 className (기본: "overflow-auto") — "overflow-hidden"으로 변경하면 내부 flex 레이아웃이 고정 높이 내에서 동작 */ + contentClassName?: string; } export function FullscreenDialog({ @@ -38,6 +40,7 @@ export function FullscreenDialog({ defaultMaxWidth = "max-w-5xl", defaultWidth = "w-[95vw]", className, + contentClassName, }: FullscreenDialogProps) { const [isFullscreen, setIsFullscreen] = useState(false); @@ -73,7 +76,7 @@ export function FullscreenDialog({
-
+
{children}
diff --git a/frontend/components/common/SmartSelect.tsx b/frontend/components/common/SmartSelect.tsx new file mode 100644 index 00000000..9c160f47 --- /dev/null +++ b/frontend/components/common/SmartSelect.tsx @@ -0,0 +1,122 @@ +"use client"; + +/** + * SmartSelect + * + * 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트. + * - 옵션 5개 미만: 기본 Select (드롭다운) + * - 옵션 5개 이상: Combobox (검색 + 드롭다운) + */ + +import React, { useState, useMemo } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const SEARCH_THRESHOLD = 5; + +export interface SmartSelectOption { + code: string; + label: string; +} + +interface SmartSelectProps { + options: SmartSelectOption[]; + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function SmartSelect({ + options, + value, + onValueChange, + placeholder = "선택", + disabled = false, + className, +}: SmartSelectProps) { + const [open, setOpen] = useState(false); + + const selectedLabel = useMemo( + () => options.find((o) => o.code === value)?.label, + [options, value], + ); + + if (options.length < SEARCH_THRESHOLD) { + return ( + + ); + } + + return ( + + + + + + { + if (!search) return 1; + return val.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }} + > + + + 검색 결과가 없습니다. + + {options.map((o) => ( + { + onValueChange(o.code); + setOpen(false); + }} + > + + {o.label} + + ))} + + + + + + ); +} diff --git a/frontend/lib/api/receiving.ts b/frontend/lib/api/receiving.ts index 8a27849a..f890609d 100644 --- a/frontend/lib/api/receiving.ts +++ b/frontend/lib/api/receiving.ts @@ -50,6 +50,7 @@ export interface PurchaseOrderSource { unit_price: number; status: string; due_date: string | null; + source_table: string; } export interface ShipmentSource { @@ -156,24 +157,30 @@ export async function getReceivingWarehouses() { return res.data as { success: boolean; data: WarehouseOption[] }; } -// 소스 데이터 조회 -export async function getPurchaseOrderSources(keyword?: string) { +// 소스 데이터 조회 (페이징) +interface SourceParams { + keyword?: string; + page?: number; + pageSize?: number; +} + +export async function getPurchaseOrderSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/purchase-orders", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: PurchaseOrderSource[] }; + return res.data as { success: boolean; data: PurchaseOrderSource[]; totalCount: number }; } -export async function getShipmentSources(keyword?: string) { +export async function getShipmentSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/shipments", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: ShipmentSource[] }; + return res.data as { success: boolean; data: ShipmentSource[]; totalCount: number }; } -export async function getItemSources(keyword?: string) { +export async function getItemSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/items", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: ItemSource[] }; + return res.data as { success: boolean; data: ItemSource[]; totalCount: number }; } diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 8c42b957..acd9a183 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -11,6 +11,7 @@ import { FileViewerModal } from "./FileViewerModal"; import { FileManagerModal } from "./FileManagerModal"; import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types"; import { useAuth } from "@/hooks/useAuth"; +import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; import { Upload, File, @@ -64,7 +65,6 @@ const getFileIcon = (extension: string) => { export interface FileUploadComponentProps { component: any; componentConfig: FileUploadConfig; - componentStyle: React.CSSProperties; className: string; isInteractive: boolean; isDesignMode: boolean; @@ -82,7 +82,6 @@ export interface FileUploadComponentProps { const FileUploadComponent: React.FC = ({ component, componentConfig, - componentStyle, className, isInteractive, isDesignMode = false, // 기본값 설정 @@ -187,7 +186,7 @@ const FileUploadComponent: React.FC = ({ } } } catch (e) { - console.warn("컴포넌트 마운트 시 파일 복원 실패:", e); + // silently ignore } }, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 @@ -259,7 +258,7 @@ const FileUploadComponent: React.FC = ({ filesLoadedFromObjidRef.current = true; } } catch (error) { - console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); + // silently ignore } })(); }, [imageObjidFromFormData, columnName, component.id]); @@ -287,7 +286,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(newFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } // 전역 상태 업데이트 (🆕 고유 키 사용) @@ -346,11 +345,6 @@ const FileUploadComponent: React.FC = ({ // 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도 if (!screenId) { - console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", { - componentId: component.id, - pathname: window.location.pathname, - formData: formData, - }); // screenId를 0으로 설정하여 컴포넌트 ID로만 조회 screenId = 0; } @@ -400,7 +394,7 @@ const FileUploadComponent: React.FC = ({ finalFiles = [...formattedFiles, ...additionalFiles]; } } catch (e) { - console.warn("파일 병합 중 오류:", e); + // silently ignore } setUploadedFiles(finalFiles); @@ -424,13 +418,13 @@ const FileUploadComponent: React.FC = ({ try { localStorage.setItem(uniqueKey, JSON.stringify(finalFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } } return true; // 새로운 로직 사용됨 } } catch (error) { - console.error("파일 조회 오류:", error); + // silently ignore } return false; // 기존 로직 사용 }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]); @@ -503,7 +497,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(files)); } catch (e) { - console.warn("localStorage 백업 실패:", e); + // silently ignore } } }; @@ -690,11 +684,9 @@ const FileUploadComponent: React.FC = ({ })); allNewFiles.push(...chunkFiles); } else { - console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response); failedChunks++; } } catch (chunkError) { - console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError); failedChunks++; } } @@ -714,7 +706,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { - console.warn("localStorage 백업 실패:", e); + // silently ignore } // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) @@ -752,8 +744,6 @@ const FileUploadComponent: React.FC = ({ uploadedFiles: updatedFiles, lastFileUpdate: timestamp, }); - } else { - console.warn("⚠️ onUpdate 콜백이 없습니다!"); } // 이미지/파일 컬럼에 objid 저장 (formData 업데이트) @@ -797,7 +787,6 @@ const FileUploadComponent: React.FC = ({ toast.success(`${allNewFiles.length}개 파일 업로드 완료`); } } catch (error) { - console.error("파일 업로드 오류:", error); setUploadStatus("error"); toast.dismiss("file-upload"); toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); @@ -828,7 +817,6 @@ const FileUploadComponent: React.FC = ({ }); toast.success(`${file.realFileName} 다운로드 완료`); } catch (error) { - console.error("파일 다운로드 오류:", error); toast.error("파일 다운로드에 실패했습니다."); } }, []); @@ -851,7 +839,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) @@ -903,7 +891,6 @@ const FileUploadComponent: React.FC = ({ toast.success(`${fileName} 삭제 완료`); } catch (error) { - console.error("파일 삭제 오류:", error); toast.error("파일 삭제에 실패했습니다."); } }, @@ -925,7 +912,6 @@ const FileUploadComponent: React.FC = ({ // objid가 없거나 유효하지 않으면 로드 중단 if (!file.objid || file.objid === "0" || file.objid === "") { - console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file); setRepresentativeImageUrl(null); return; } @@ -950,11 +936,6 @@ const FileUploadComponent: React.FC = ({ setRepresentativeImageUrl(url); } catch (error: any) { - console.error("❌ 대표 이미지 로드 실패:", { - file: file.realFileName, - objid: file.objid, - error: error?.response?.status || error?.message, - }); setRepresentativeImageUrl(null); } }, @@ -980,7 +961,7 @@ const FileUploadComponent: React.FC = ({ // 대표 이미지 로드 loadRepresentativeImage(file); } catch (e) { - console.error("❌ 대표 파일 설정 실패:", e); + // silently ignore } }, [uploadedFiles, component.id, loadRepresentativeImage] @@ -1050,25 +1031,53 @@ const FileUploadComponent: React.FC = ({ [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick], ); - // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값) + // 🔧 커스텀 스타일 감지 및 추출 (StyleEditor에서 설정한 값, component.style에서 직접 읽기) const customStyle = component.style || {}; const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border); const hasCustomBackground = !!customStyle.backgroundColor; const hasCustomRadius = !!customStyle.borderRadius; + // 커스텀 border inline style 구축 + const customBorderStyle: React.CSSProperties = hasCustomBorder + ? { + ...(customStyle.border + ? { border: customStyle.border } + : { + borderWidth: customStyle.borderWidth || "1px", + borderStyle: customStyle.borderStyle || "solid", + borderColor: customStyle.borderColor, + }), + } + : {}; + + // 커스텀 배경/radius inline style + const customBackgroundStyle: React.CSSProperties = hasCustomBackground + ? { backgroundColor: customStyle.backgroundColor } + : {}; + const customRadiusStyle: React.CSSProperties = hasCustomRadius + ? { borderRadius: customStyle.borderRadius } + : {}; + + // 커스텀 텍스트 style (내부 텍스트 요소에 전파) + const customTextStyle: React.CSSProperties = { + ...(customStyle.color ? { color: customStyle.color } : {}), + ...(customStyle.fontSize ? { fontSize: customStyle.fontSize } : {}), + ...(customStyle.fontWeight ? { fontWeight: customStyle.fontWeight } : {}), + }; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
= ({ top: "-20px", left: "0px", fontSize: customStyle.labelFontSize || "12px", - color: customStyle.labelColor || "rgb(107, 114, 128)", + color: getAdaptiveLabelColor(customStyle.labelColor || "rgb(107, 114, 128)"), fontWeight: customStyle.labelFontWeight || "400", background: "transparent", border: "none", @@ -1106,6 +1115,11 @@ const FileUploadComponent: React.FC = ({ // 커스텀 배경이 없을 때만 기본 배경 표시 !hasCustomBackground && "bg-card", )} + style={{ + ...(hasCustomBorder ? { ...customBorderStyle, ...customRadiusStyle } : {}), + ...(hasCustomBackground ? customBackgroundStyle : {}), + ...(hasCustomRadius && !hasCustomBorder ? customRadiusStyle : {}), + }} > {/* 대표 이미지 전체 화면 표시 */} {uploadedFiles.length > 0 ? (() => { @@ -1155,7 +1169,10 @@ const FileUploadComponent: React.FC = ({ ); })() : ( -
+

업로드된 파일이 없습니다