diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index dd257714..1c6a9d9e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -156,6 +156,7 @@ import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리 import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) +import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지 import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 @@ -376,6 +377,7 @@ app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 +app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지 app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/receiving", receivingRoutes); // 입고관리 diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 08506f66..268b8274 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -118,7 +118,7 @@ export async function create(req: AuthenticatedRequest, res: Response) { for (const item of items) { const result = await client.query( `INSERT INTO outbound_mng ( - company_code, outbound_number, outbound_type, outbound_date, + id, company_code, outbound_number, outbound_type, outbound_date, reference_number, customer_code, customer_name, item_code, item_name, specification, material, unit, outbound_qty, unit_price, total_amount, @@ -128,7 +128,7 @@ export async function create(req: AuthenticatedRequest, res: Response) { destination_code, delivery_destination, delivery_address, created_date, created_by, writer, status ) VALUES ( - $1, $2, $3, $4, + gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 4c28dfed..6ce685fb 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -29,7 +29,7 @@ async function copyChecklistToSplit( if (routingDetailId) { const result = await client.query( `INSERT INTO process_work_result ( - company_code, work_order_process_id, + id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -38,7 +38,7 @@ async function copyChecklistToSplit( status, writer ) SELECT - pwi.company_code, $1, + gen_random_uuid()::text, pwi.company_code, $1, pwi.id, pwd.id, pwi.work_phase, pwi.title, pwi.sort_order::text, pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, @@ -59,7 +59,7 @@ async function copyChecklistToSplit( // B. routing_detail_id가 없으면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) const result = await client.query( `INSERT INTO process_work_result ( - company_code, work_order_process_id, + id, company_code, work_order_process_id, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -68,7 +68,7 @@ async function copyChecklistToSplit( status, writer ) SELECT - company_code, $1, + gen_random_uuid()::text, company_code, $1, source_work_item_id, source_detail_id, work_phase, item_title, item_sort_order, detail_content, detail_type, detail_sort_order, is_required, @@ -168,10 +168,10 @@ export const createWorkProcesses = async ( // 2. work_order_process INSERT const wopResult = await client.query( `INSERT INTO work_order_process ( - company_code, wo_id, seq_no, process_code, process_name, + id, company_code, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, plan_qty, status, routing_detail_id, writer - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, [ companyCode, @@ -778,13 +778,13 @@ export const saveResult = async ( const masterId = proc.parent_process_id || work_order_process_id; const reworkInsert = await pool.query( `INSERT INTO work_order_process ( - wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty, result_status, is_rework, rework_source_id, parent_process_id, company_code, writer ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, + gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'acceptable', $10, '0', '0', '0', '0', 'draft', 'Y', $11, $12, $13, $14 @@ -1444,13 +1444,13 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => // 분할 행 INSERT (원본 행에서 공정 정보 복사) const result = await pool.query( `INSERT INTO work_order_process ( - wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, total_production_qty, result_status, accepted_by, accepted_at, started_at, parent_process_id, company_code, writer ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, + gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'in_progress', $10, '0', '0', '0', 'draft', $11, NOW()::text, NOW()::text, $12, $13, $11 @@ -1607,3 +1607,355 @@ export const cancelAccept = async ( }); } }; + +// ======================================== +// POP 전용 함수 (PC 코드와 분리) +// ======================================== + +/** + * 내부 헬퍼: 단일 작업지시에 대해 work_order_process + process_work_result 일괄 생성 + * syncWorkInstructions에서 사용한다. + */ +async function generateWorkProcessesForInstruction( + client: { query: (text: string, values?: any[]) => Promise }, + workInstructionId: string, + routingVersionId: string, + planQty: string | null, + companyCode: string, + userId: string +): Promise<{ + processes: Array<{ id: string; seq_no: string; process_name: string; checklist_count: number }>; + total_checklists: number; +} | null> { + const existCheck = await client.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [workInstructionId, companyCode] + ); + if (parseInt(existCheck.rows[0].cnt, 10) > 0) { + return null; + } + + const routingDetails = await client.query( + `SELECT rd.id, rd.seq_no, rd.process_code, + COALESCE(pm.process_name, rd.process_code) as process_name, + rd.is_required, rd.is_fixed_order, rd.standard_time + FROM item_routing_detail rd + LEFT JOIN process_mng pm ON pm.process_code = rd.process_code + AND pm.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY CAST(rd.seq_no AS int) NULLS LAST`, + [routingVersionId, companyCode] + ); + + if (routingDetails.rows.length === 0) { + return null; + } + + const processes: Array<{ + id: string; seq_no: string; process_name: string; checklist_count: number; + }> = []; + let totalChecklists = 0; + + for (const rd of routingDetails.rows) { + const wopResult = await client.query( + `INSERT INTO work_order_process ( + id, company_code, wo_id, seq_no, process_code, process_name, + is_required, is_fixed_order, standard_time, plan_qty, + status, routing_detail_id, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id`, + [ + companyCode, workInstructionId, rd.seq_no, rd.process_code, rd.process_name, + rd.is_required, rd.is_fixed_order, rd.standard_time, planQty || null, + parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting", rd.id, userId, + ] + ); + const wopId = wopResult.rows[0].id; + + const checklistCount = await copyChecklistToSplit( + client, wopId, wopId, rd.id, companyCode, userId + ); + totalChecklists += checklistCount; + + processes.push({ + id: wopId, seq_no: rd.seq_no, process_name: rd.process_name, checklist_count: checklistCount, + }); + } + + return { processes, total_checklists: totalChecklists }; +} + +/** + * POP: 미동기화 작업지시 일괄 동기화 + */ +export const syncWorkInstructions = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const unsyncedResult = await pool.query( + `SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty + FROM work_instruction wi + WHERE wi.company_code = $1 + AND wi.routing IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM work_order_process wop + WHERE wop.wo_id = wi.id AND wop.company_code = $1 + )`, + [companyCode] + ); + const unsynced = unsyncedResult.rows; + if (unsynced.length === 0) { + return res.json({ success: true, data: { synced: 0, skipped: 0, errors: 0, details: [] } }); + } + let synced = 0, skipped = 0, errors = 0; + const details: Array<{ + work_instruction_id: string; work_instruction_no: string; + status: "synced" | "skipped" | "error"; process_count?: number; error?: string; + }> = []; + for (const wi of unsynced) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await generateWorkProcessesForInstruction( + client, wi.id, wi.routing, wi.qty || null, companyCode, userId + ); + if (!result) { + await client.query("ROLLBACK"); + skipped++; + details.push({ work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, status: "skipped" }); + continue; + } + await client.query("COMMIT"); + synced++; + details.push({ + work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, + status: "synced", process_count: result.processes.length, + }); + } catch (err: any) { + await client.query("ROLLBACK"); + errors++; + details.push({ + work_instruction_id: wi.id, work_instruction_no: wi.work_instruction_no, + status: "error", error: err.message || "알 수 없는 오류", + }); + } finally { + client.release(); + } + } + return res.json({ success: true, data: { synced, skipped, errors, details } }); + } catch (error: any) { + logger.error("[pop/production] sync-work-instructions 오류:", error); + return res.status(500).json({ success: false, message: error.message || "동기화 오류" }); + } +}; + +export const getWarehouses = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const result = await pool.query( + `SELECT id, warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`, + [companyCode] + ); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + return res.status(500).json({ success: false, message: error.message }); + } +}; + +export const getWarehouseLocations = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { warehouseId } = req.params; + if (!warehouseId) return res.status(400).json({ success: false, message: "warehouseId 필수" }); + const whInfo = await pool.query( + `SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`, + [warehouseId, companyCode] + ); + if (whInfo.rowCount === 0) return res.json({ success: true, data: [] }); + const result = await pool.query( + `SELECT id, location_code, location_name FROM warehouse_location + WHERE warehouse_code = $1 AND company_code = $2 ORDER BY location_name`, + [whInfo.rows[0].warehouse_code, companyCode] + ); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + return res.status(500).json({ success: false, message: error.message }); + } +}; + +export const isLastProcess = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) return res.json({ success: true, data: { isLast: false } }); + const process = await pool.query( + `SELECT wo_id, seq_no, parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [processId, companyCode] + ); + if (process.rowCount === 0) return res.json({ success: true, data: { isLast: false } }); + const { wo_id, seq_no, parent_process_id } = process.rows[0]; + let effectiveSeqNo = seq_no; + if (parent_process_id) { + const master = await pool.query( + `SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`, + [parent_process_id, companyCode] + ); + if (master.rowCount > 0) effectiveSeqNo = master.rows[0].seq_no; + } + const next = await pool.query( + `SELECT id FROM work_order_process + WHERE wo_id = $1 AND company_code = $2 AND CAST(seq_no AS int) > CAST($3 AS int) AND parent_process_id IS NULL LIMIT 1`, + [wo_id, companyCode, effectiveSeqNo] + ); + const warehouseInfo = await pool.query( + `SELECT target_warehouse_id, target_location_code FROM work_order_process WHERE id = $1 AND company_code = $2`, + [processId, companyCode] + ); + return res.json({ + success: true, + data: { + isLast: next.rowCount === 0, woId: wo_id, seqNo: effectiveSeqNo, + targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null, + targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null, + }, + }); + } catch (error: any) { + return res.status(500).json({ success: false, message: error.message }); + } +}; + +export const updateTargetWarehouse = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, target_warehouse_id, target_location_code } = req.body; + if (!work_order_process_id || !target_warehouse_id) + return res.status(400).json({ success: false, message: "work_order_process_id와 target_warehouse_id 필수" }); + const procInfo = await pool.query( + `SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + const idsToUpdate = [work_order_process_id]; + if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) idsToUpdate.push(procInfo.rows[0].parent_process_id); + for (const id of idsToUpdate) { + await pool.query( + `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [id, companyCode, target_warehouse_id, target_location_code || null, userId] + ); + } + return res.json({ success: true, data: { target_warehouse_id, target_location_code } }); + } catch (error: any) { + return res.status(500).json({ success: false, message: error.message }); + } +}; + +export const inventoryInbound = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, warehouse_code, location_code } = req.body; + if (!work_order_process_id || !warehouse_code) + return res.status(400).json({ success: false, message: "work_order_process_id와 warehouse_code 필수" }); + await client.query("BEGIN"); + const procResult = await client.query( + `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + if (procResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "공정 없음" }); } + const proc = procResult.rows[0]; + if (proc.target_warehouse_id) { await client.query("ROLLBACK"); return res.status(409).json({ success: false, message: "이미 입고 완료" }); } + const goodQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10); + if (goodQty <= 0) { await client.query("ROLLBACK"); return res.status(400).json({ success: false, message: "양품 0" }); } + const wiResult = await client.query(`SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`, [proc.wo_id, companyCode]); + if (wiResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "작업지시 없음" }); } + const itemResult = await client.query(`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`, [wiResult.rows[0].item_id, companyCode]); + if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 없음" }); } + const itemCode = itemResult.rows[0].item_number; + const locCode = location_code || warehouse_code; + await client.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6) + ON CONFLICT (company_code, item_code, warehouse_code, location_code) + DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text, + last_in_date = NOW(), updated_date = NOW(), writer = $6`, + [companyCode, itemCode, warehouse_code, locCode, String(goodQty), userId] + ); + const idsToUpdate = [work_order_process_id]; + if (proc.parent_process_id) idsToUpdate.push(proc.parent_process_id); + for (const id of idsToUpdate) { + await client.query( + `UPDATE work_order_process SET target_warehouse_id = $3, target_location_code = $4, writer = $5, updated_date = NOW() WHERE id = $1 AND company_code = $2`, + [id, companyCode, warehouse_code, location_code || null, userId] + ); + } + await client.query("COMMIT"); + return res.json({ success: true, message: "재고 입고 완료", data: { item_code: itemCode, warehouse_code, location_code: locCode, qty: goodQty } }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + return res.status(500).json({ success: false, message: error.message }); + } finally { client.release(); } +}; + +export const quickInventoryInbound = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { item_id, qty, warehouse_code, location_code, remark } = req.body; + if (!item_id || !qty || !warehouse_code) + return res.status(400).json({ success: false, message: "item_id, qty, warehouse_code 필수" }); + const parsedQty = parseInt(String(qty), 10); + if (isNaN(parsedQty) || parsedQty <= 0) + return res.status(400).json({ success: false, message: "수량은 1 이상" }); + await client.query("BEGIN"); + const itemResult = await client.query( + `SELECT item_number, item_name, size, material, unit FROM item_info WHERE id = $1 AND company_code = $2`, [item_id, companyCode] + ); + if (itemResult.rowCount === 0) { await client.query("ROLLBACK"); return res.status(404).json({ success: false, message: "품목 없음" }); } + const item = itemResult.rows[0]; + const locCode = location_code || warehouse_code; + await client.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6) + ON CONFLICT (company_code, item_code, warehouse_code, location_code) + DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text, + last_in_date = NOW(), updated_date = NOW(), writer = $6`, + [companyCode, item.item_number, warehouse_code, locCode, String(parsedQty), userId] + ); + const seqResult = await client.query( + `SELECT COALESCE(MAX(CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$' + THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER) ELSE 0 END), 0) + 1 AS next_seq + FROM inbound_mng WHERE company_code = $1`, [companyCode] + ); + const inboundNumber = `QIB-${new Date().getFullYear()}-${String(seqResult.rows[0].next_seq).padStart(4, "0")}`; + await client.query( + `INSERT INTO inbound_mng (id, company_code, inbound_number, inbound_type, inbound_date, + item_number, item_name, spec, material, unit, inbound_qty, warehouse_code, location_code, + inbound_status, memo, remark, created_date, updated_date, writer, created_by, updated_by + ) VALUES (gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE, + $3, $4, $5, $6, $7, $8, $9, $10, '완료', $11, $12, NOW(), NOW(), $13, $13, $13)`, + [companyCode, inboundNumber, item.item_number, item.item_name, item.size, item.material, item.unit, + parsedQty, warehouse_code, locCode, remark || "POP 간이입고", remark || null, userId] + ); + await client.query("COMMIT"); + return res.json({ success: true, message: "간이 입고 완료", + data: { inbound_number: inboundNumber, item_code: item.item_number, item_name: item.item_name, warehouse_code, location_code: locCode, qty: parsedQty } }); + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + return res.status(500).json({ success: false, message: error.message }); + } finally { client.release(); } +}; diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index f7d9dfb4..51986e3c 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -246,10 +246,10 @@ export async function create(req: AuthenticatedRequest, res: Response) { } else { await client.query( `INSERT INTO inventory_stock ( - company_code, item_code, warehouse_code, location_code, + id, company_code, item_code, warehouse_code, location_code, current_qty, safety_qty, last_in_date, created_date, updated_date, writer - ) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, [companyCode, itemCode, whCode, locCode, String(inQty), userId] ); } diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index 57417797..921ddf92 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; import { createWorkProcesses, + syncWorkInstructions, controlTimer, controlGroupTimer, getDefectTypes, @@ -11,6 +12,12 @@ import { getAvailableQty, acceptProcess, cancelAccept, + getWarehouses, + getWarehouseLocations, + isLastProcess, + updateTargetWarehouse, + inventoryInbound, + quickInventoryInbound, } from "../controllers/popProductionController"; const router = Router(); @@ -18,6 +25,7 @@ const router = Router(); router.use(authenticateToken); router.post("/create-work-processes", createWorkProcesses); +router.post("/sync-work-instructions", syncWorkInstructions); router.post("/timer", controlTimer); router.post("/group-timer", controlGroupTimer); router.get("/defect-types", getDefectTypes); @@ -27,5 +35,11 @@ router.get("/result-history", getResultHistory); router.get("/available-qty", getAvailableQty); router.post("/accept-process", acceptProcess); router.post("/cancel-accept", cancelAccept); +router.get("/warehouses", getWarehouses); +router.get("/warehouse-locations/:warehouseId", getWarehouseLocations); +router.get("/is-last-process/:processId", isLastProcess); +router.post("/update-target-warehouse", updateTargetWarehouse); +router.post("/inventory-inbound", inventoryInbound); +router.post("/quick-inventory-inbound", quickInventoryInbound); export default router; diff --git a/frontend/app/(pop)/pop/home/page.tsx b/frontend/app/(pop)/pop/home/page.tsx new file mode 100644 index 00000000..f9d8c0ac --- /dev/null +++ b/frontend/app/(pop)/pop/home/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { PopShell, KpiCarousel, MenuIcons, RecentActivity } from "@/components/pop/hardcoded"; + +export default function PopHomePage() { + return ( + + + + + + ); +} diff --git a/frontend/app/(pop)/pop/inbound/cart/page.tsx b/frontend/app/(pop)/pop/inbound/cart/page.tsx new file mode 100644 index 00000000..b6dada4f --- /dev/null +++ b/frontend/app/(pop)/pop/inbound/cart/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { InboundCartPage } from "@/components/pop/hardcoded/inbound/InboundCartPage"; + +export default function InboundCartRoute() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/inbound/page.tsx b/frontend/app/(pop)/pop/inbound/page.tsx new file mode 100644 index 00000000..baf827bd --- /dev/null +++ b/frontend/app/(pop)/pop/inbound/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { InboundTypeSelect } from "@/components/pop/hardcoded/inbound"; + +export default function InboundPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/inbound/purchase/page.tsx b/frontend/app/(pop)/pop/inbound/purchase/page.tsx new file mode 100644 index 00000000..e558ced6 --- /dev/null +++ b/frontend/app/(pop)/pop/inbound/purchase/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { PopShell } from "@/components/pop/hardcoded"; +import { PurchaseInbound } from "@/components/pop/hardcoded/inbound"; +import { useCartSync } from "@/components/pop/hardcoded/common/useCartSync"; + +export default function PurchaseInboundPage() { + const router = useRouter(); + const cart = useCartSync("pop-purchase-inbound", "purchase_detail"); + const [saving, setSaving] = useState(false); + + const handleCartClick = async () => { + if (cart.isDirty) { + setSaving(true); + const ok = await cart.saveToDb(); + setSaving(false); + if (!ok) return; // save failed, don't navigate + } + router.push("/pop/inbound/cart"); + }; + + return ( + + {saving ? ( + + + + + ) : ( + + + + )} + {cart.cartCount > 0 && ( + + {cart.cartCount} + + )} + + } + > + + + ); +} diff --git a/frontend/app/(pop)/pop/outbound/cart/page.tsx b/frontend/app/(pop)/pop/outbound/cart/page.tsx new file mode 100644 index 00000000..a04273b8 --- /dev/null +++ b/frontend/app/(pop)/pop/outbound/cart/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { OutboundCartPage } from "@/components/pop/hardcoded/outbound/OutboundCartPage"; + +export default function OutboundCartRoute() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/outbound/page.tsx b/frontend/app/(pop)/pop/outbound/page.tsx new file mode 100644 index 00000000..4e71fa5f --- /dev/null +++ b/frontend/app/(pop)/pop/outbound/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { OutboundTypeSelect } from "@/components/pop/hardcoded/outbound"; + +export default function OutboundPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/outbound/sales/page.tsx b/frontend/app/(pop)/pop/outbound/sales/page.tsx new file mode 100644 index 00000000..e93d3c74 --- /dev/null +++ b/frontend/app/(pop)/pop/outbound/sales/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { PopShell } from "@/components/pop/hardcoded"; +import { SalesOutbound } from "@/components/pop/hardcoded/outbound"; +import { useCartSync } from "@/components/pop/hardcoded/common/useCartSync"; + +export default function SalesOutboundPage() { + const router = useRouter(); + const cart = useCartSync("pop-sales-outbound", "shipment_instruction_detail"); + const [saving, setSaving] = useState(false); + + const handleCartClick = async () => { + if (cart.isDirty) { + setSaving(true); + const ok = await cart.saveToDb(); + setSaving(false); + if (!ok) return; + } + router.push("/pop/outbound/cart"); + }; + + return ( + + {saving ? ( + + + + + ) : ( + + + + )} + {cart.cartCount > 0 && ( + + {cart.cartCount} + + )} + + } + > + + + ); +} diff --git a/frontend/app/(pop)/pop/production/page.tsx b/frontend/app/(pop)/pop/production/page.tsx new file mode 100644 index 00000000..0ad08502 --- /dev/null +++ b/frontend/app/(pop)/pop/production/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { ProductionMain } from "@/components/pop/hardcoded/production"; + +export default function ProductionPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/production/process/page.tsx b/frontend/app/(pop)/pop/production/process/page.tsx new file mode 100644 index 00000000..4c45e6a9 --- /dev/null +++ b/frontend/app/(pop)/pop/production/process/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PopShell } from "@/components/pop/hardcoded"; +import { WorkOrderList } from "@/components/pop/hardcoded/production"; + +export default function ProductionProcessPage() { + return ( + + + + ); +} diff --git a/frontend/app/(pop)/pop/production/work/[processId]/page.tsx b/frontend/app/(pop)/pop/production/work/[processId]/page.tsx new file mode 100644 index 00000000..a055bc29 --- /dev/null +++ b/frontend/app/(pop)/pop/production/work/[processId]/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { PopShell } from "@/components/pop/hardcoded"; +import { ProcessWork } from "@/components/pop/hardcoded/production"; + +export default function ProcessWorkPage() { + const params = useParams(); + const processId = params.processId as string; + + return ( + + + + ); +} diff --git a/frontend/components/pop/hardcoded/KpiCarousel.tsx b/frontend/components/pop/hardcoded/KpiCarousel.tsx new file mode 100644 index 00000000..73ec648f --- /dev/null +++ b/frontend/components/pop/hardcoded/KpiCarousel.tsx @@ -0,0 +1,465 @@ +"use client"; + +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { apiClient } from "@/lib/api/client"; + +/* ------------------------------------------------------------------ */ +/* KPI Data Types */ +/* ------------------------------------------------------------------ */ + +interface SummaryKpi { + totalWork: number; + inProgress: number; + completed: number; + issues: number; +} + +interface InOutKpi { + inboundTotal: number; + inboundCompleted: number; + inboundInProgress: number; + outboundTotal: number; + outboundCompleted: number; + outboundInProgress: number; +} + +interface ProdQualityKpi { + productionTotal: number; + productionCompleted: number; + productionInProgress: number; + qualityRate: number | null; // null = no data +} + + +export function KpiCarousel() { + const [current, setCurrent] = useState(0); + const trackRef = useRef(null); + const autoTimerRef = useRef | null>(null); + const startXRef = useRef(0); + const moveXRef = useRef(0); + const draggingRef = useRef(false); + + /* KPI state */ + const [summary, setSummary] = useState({ + totalWork: 0, inProgress: 0, completed: 0, issues: 0, + }); + const [inOut, setInOut] = useState({ + inboundTotal: 0, inboundCompleted: 0, inboundInProgress: 0, + outboundTotal: 0, outboundCompleted: 0, outboundInProgress: 0, + }); + const [prodQuality, setProdQuality] = useState({ + productionTotal: 0, productionCompleted: 0, productionInProgress: 0, + qualityRate: null, + }); + + const total = 3; + + /* Fetch real data */ + useEffect(() => { + const fetchKpi = async () => { + try { + const today = new Date().toISOString().slice(0, 10); + const [inboundRes, outboundRes] = await Promise.all([ + apiClient.get("/receiving/list", { + params: { date_from: today, date_to: today }, + }), + apiClient.get("/outbound/list", { + params: { date_from: today, date_to: today }, + }), + ]); + + const inboundData: Record[] = inboundRes.data?.data ?? []; + const outboundData: Record[] = outboundRes.data?.data ?? []; + + // Inbound stats + const inboundTotal = inboundData.length; + const inboundCompleted = inboundData.filter( + (r) => r.inbound_status === "완료" || r.inbound_status === "입고완료" + ).length; + const inboundInProgress = inboundTotal - inboundCompleted; + + // Outbound stats + const outboundTotal = outboundData.length; + const outboundCompleted = outboundData.filter( + (r) => r.outbound_status === "완료" || r.outbound_status === "출고완료" + ).length; + const outboundInProgress = outboundTotal - outboundCompleted; + + // Summary KPI: total = inbound + outbound + const totalWork = inboundTotal + outboundTotal; + const completed = inboundCompleted + outboundCompleted; + const inProgress = inboundInProgress + outboundInProgress; + + setSummary({ + totalWork, + inProgress, + completed, + issues: 0, + }); + + setInOut({ + inboundTotal, + inboundCompleted, + inboundInProgress, + outboundTotal, + outboundCompleted, + outboundInProgress, + }); + + // Production / Quality - no dedicated API yet, set to 0 + setProdQuality({ + productionTotal: 0, + productionCompleted: 0, + productionInProgress: 0, + qualityRate: null, + }); + } catch { + // On failure, keep zeros (no dummy data) + } + }; + + fetchKpi(); + }, []); + + const goTo = useCallback( + (idx: number) => { + const next = ((idx % total) + total) % total; + setCurrent(next); + }, + [total] + ); + + const startAuto = useCallback(() => { + if (autoTimerRef.current) clearInterval(autoTimerRef.current); + autoTimerRef.current = setInterval(() => { + setCurrent((prev) => ((prev + 1) % total + total) % total); + }, 5000); + }, [total]); + + const stopAuto = useCallback(() => { + if (autoTimerRef.current) { + clearInterval(autoTimerRef.current); + autoTimerRef.current = null; + } + }, []); + + useEffect(() => { + startAuto(); + return () => stopAuto(); + }, [startAuto, stopAuto]); + + // Touch handlers + const handleTouchStart = (e: React.TouchEvent) => { + startXRef.current = e.touches[0].clientX; + draggingRef.current = true; + stopAuto(); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!draggingRef.current) return; + moveXRef.current = e.touches[0].clientX - startXRef.current; + }; + + const handleTouchEnd = () => { + if (!draggingRef.current) return; + draggingRef.current = false; + if (Math.abs(moveXRef.current) > 50) { + if (moveXRef.current < 0) goTo(current + 1); + else goTo(current - 1); + } + moveXRef.current = 0; + startAuto(); + }; + + // Mouse handlers (desktop drag) + const handleMouseDown = (e: React.MouseEvent) => { + startXRef.current = e.clientX; + draggingRef.current = true; + stopAuto(); + e.preventDefault(); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!draggingRef.current) return; + moveXRef.current = e.clientX - startXRef.current; + }; + + const handleMouseUp = () => { + if (!draggingRef.current) return; + draggingRef.current = false; + if (Math.abs(moveXRef.current) > 50) { + if (moveXRef.current < 0) goTo(current + 1); + else goTo(current - 1); + } + moveXRef.current = 0; + startAuto(); + }; + + const handleMouseLeave = () => { + if (draggingRef.current) { + draggingRef.current = false; + moveXRef.current = 0; + startAuto(); + } + }; + + const handleDotClick = (idx: number) => { + stopAuto(); + goTo(idx); + startAuto(); + }; + + /* Computed slide values */ + const inboundPercent = inOut.inboundTotal > 0 + ? Math.round((inOut.inboundCompleted / inOut.inboundTotal) * 100) + : 0; + const outboundPercent = inOut.outboundTotal > 0 + ? Math.round((inOut.outboundCompleted / inOut.outboundTotal) * 100) + : 0; + const productionPercent = prodQuality.productionTotal > 0 + ? Math.round((prodQuality.productionCompleted / prodQuality.productionTotal) * 100) + : 0; + + return ( +
+

+ 오늘의 현황 +

+ + {/* Carousel container */} +
+
+ {/* Slide 1: Summary KPI */} +
+
+
+ + + + +
+
+
+ + {/* Slide 2: Receiving + Shipping */} +
+
+ + + + } + iconGradient="linear-gradient(135deg,#3b82f6,#1d4ed8)" + title="입고" + badge={`${inOut.inboundInProgress}건 진행`} + badgeColor="text-blue-600 bg-blue-50" + value={inOut.inboundCompleted} + total={inOut.inboundTotal} + percent={inboundPercent} + barColor="bg-blue-500" + /> + + + + } + iconGradient="linear-gradient(135deg,#22c55e,#15803d)" + title="출고" + badge={`${inOut.outboundInProgress}건 진행`} + badgeColor="text-green-600 bg-green-50" + value={inOut.outboundCompleted} + total={inOut.outboundTotal} + percent={outboundPercent} + barColor="bg-green-500" + /> +
+
+ + {/* Slide 3: Production + Quality */} +
+
+ + + + } + iconGradient="linear-gradient(135deg,#f59e0b,#d97706)" + title="생산" + badge={`${prodQuality.productionInProgress}건 진행`} + badgeColor="text-amber-600 bg-amber-50" + value={prodQuality.productionCompleted} + total={prodQuality.productionTotal > 0 ? prodQuality.productionTotal : null} + percent={productionPercent} + barColor="bg-amber-500" + /> + + + + } + iconGradient="linear-gradient(135deg,#ef4444,#b91c1c)" + title="품질" + badge={prodQuality.qualityRate !== null ? "정상" : "데이터 없음"} + badgeColor={prodQuality.qualityRate !== null ? "text-green-600 bg-green-50" : "text-gray-500 bg-gray-50"} + value={prodQuality.qualityRate !== null ? prodQuality.qualityRate : 0} + total={null} + percent={prodQuality.qualityRate !== null ? prodQuality.qualityRate : 0} + barColor="bg-green-500" + valueColor={prodQuality.qualityRate !== null ? "text-green-600" : "text-gray-400"} + unit={prodQuality.qualityRate !== null ? "%" : undefined} + emptyText={prodQuality.qualityRate === null ? "-" : undefined} + /> +
+
+
+ + {/* Dots */} +
+ {[0, 1, 2].map((idx) => ( +
+
+
+ ); +} + +/* ---- Sub-components ---- */ + +function KpiCell({ + value, + label, + color, + labelColor, + border = false, +}: { + value: string; + label: string; + color: string; + labelColor: string; + border?: boolean; +}) { + return ( +
+ + {value} + + + {label} + +
+ ); +} + +function KpiProgressCard({ + icon, + iconGradient, + title, + badge, + badgeColor, + value, + total, + percent, + barColor, + valueColor = "text-gray-900", + unit, + emptyText, +}: { + icon: React.ReactNode; + iconGradient: string; + title: string; + badge: string; + badgeColor: string; + value: number; + total: number | null; + percent: number; + barColor: string; + valueColor?: string; + unit?: string; + emptyText?: string; +}) { + return ( +
+
+
+
+ {icon} +
+ {title} +
+ + {badge} + +
+
+ + {emptyText ?? value} + + {total !== null && ( + / {total} + )} + {unit && {unit}} +
+
+
+
+ + +
+ ); +} diff --git a/frontend/components/pop/hardcoded/MenuIcons.tsx b/frontend/components/pop/hardcoded/MenuIcons.tsx new file mode 100644 index 00000000..94a56813 --- /dev/null +++ b/frontend/components/pop/hardcoded/MenuIcons.tsx @@ -0,0 +1,143 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; + +interface MenuIconItem { + id: string; + title: string; + gradient: string; + shadowColor: string; + icon: React.ReactNode; + href: string; +} + +const MENU_ITEMS: MenuIconItem[] = [ + { + id: "incoming", + title: "입고", + gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)", + shadowColor: "rgba(59,130,246,.3)", + icon: ( + + + + ), + href: "/pop/inbound", + }, + { + id: "outgoing", + title: "출고", + gradient: "linear-gradient(135deg,#22c55e,#15803d)", + shadowColor: "rgba(34,197,94,.3)", + icon: ( + + + + ), + href: "/pop/outbound", + }, + { + id: "production", + title: "생산", + gradient: "linear-gradient(135deg,#f59e0b,#d97706)", + shadowColor: "rgba(245,158,11,.3)", + icon: ( + + + + + ), + href: "/pop/production", + }, + { + id: "quality", + title: "품질", + gradient: "linear-gradient(135deg,#ef4444,#b91c1c)", + shadowColor: "rgba(239,68,68,.3)", + icon: ( + + + + ), + href: "/pop/screens/quality", + }, + { + id: "equipment", + title: "설비", + gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)", + shadowColor: "rgba(139,92,246,.3)", + icon: ( + + + + ), + href: "/pop/screens/equipment", + }, + { + id: "inventory", + title: "재고", + gradient: "linear-gradient(135deg,#06b6d4,#0e7490)", + shadowColor: "rgba(6,182,212,.3)", + icon: ( + + + + ), + href: "/pop/screens/inventory", + }, + // 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동 + { + id: "safety", + title: "안전관리", + gradient: "linear-gradient(135deg,#f97316,#c2410c)", + shadowColor: "rgba(249,115,22,.3)", + icon: ( + + + + ), + href: "/pop/screens/safety", + }, +]; + +export function MenuIcons() { + const router = useRouter(); + + const handleClick = (item: MenuIconItem) => { + if (item.href === "#") { + alert(`${item.title} 화면은 준비 중입니다.`); + } else { + router.push(item.href); + } + }; + + return ( +
+

+ 메뉴 +

+
+ {MENU_ITEMS.map((item) => ( +
handleClick(item)} + > +
+ {item.icon} +
+ {item.title} +
+ ))} +
+
+ ); +} diff --git a/frontend/components/pop/hardcoded/PopShell.tsx b/frontend/components/pop/hardcoded/PopShell.tsx new file mode 100644 index 00000000..444b6193 --- /dev/null +++ b/frontend/components/pop/hardcoded/PopShell.tsx @@ -0,0 +1,344 @@ +"use client"; + +import React, { useState, useEffect, useRef, ReactNode } from "react"; +import { useRouter } from "next/navigation"; + +interface PopShellProps { + children: ReactNode; + showBanner?: boolean; + title?: string; + showBack?: boolean; + headerRight?: ReactNode; +} + +export function PopShell({ children, showBanner = true, title, showBack = false, headerRight }: PopShellProps) { + const router = useRouter(); + const [mounted, setMounted] = useState(false); + const [hours, setHours] = useState("00"); + const [minutes, setMinutes] = useState("00"); + const [seconds, setSeconds] = useState("00"); + const [dateStr, setDateStr] = useState("2026-01-01"); + const [colonVisible, setColonVisible] = useState(true); + const [profileOpen, setProfileOpen] = useState(false); + const profileRef = useRef(null); + + useEffect(() => { + setMounted(true); + + function tick() { + const now = new Date(); + setHours(String(now.getHours()).padStart(2, "0")); + setMinutes(String(now.getMinutes()).padStart(2, "0")); + setSeconds(String(now.getSeconds()).padStart(2, "0")); + setDateStr( + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}` + ); + } + + tick(); + const clockInterval = setInterval(tick, 1000); + const blinkInterval = setInterval(() => { + setColonVisible((v) => !v); + }, 500); + + return () => { + clearInterval(clockInterval); + clearInterval(blinkInterval); + }; + }, []); + + // Profile dropdown: close on outside click + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (profileRef.current && !profileRef.current.contains(e.target as Node)) { + setProfileOpen(false); + } + } + if (profileOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [profileOpen]); + + const handlePcMode = async () => { + setProfileOpen(false); + if (document.fullscreenElement) { + try { await document.exitFullscreen(); } catch {} + } + router.push("/"); + }; + + const handlePopHome = () => { + setProfileOpen(false); + router.push("/pop/home"); + }; + + const toggleFullscreen = async () => { + setProfileOpen(false); + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } else { + await document.documentElement.requestFullscreen(); + } + } catch { + // fullscreen not supported + } + }; + + const handleLogout = () => { + setProfileOpen(false); + localStorage.removeItem("token"); + localStorage.removeItem("accessToken"); + localStorage.removeItem("refreshToken"); + window.location.href = "/login"; + }; + + const marqueeText = + "[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. \u00a0\u00a0|\u00a0\u00a0 [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 \u00a0\u00a0|\u00a0\u00a0 [안내] 4월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!"; + + return ( +
+ {/* ===== HEADER ===== */} +
+ {/* Left: Back + Logo + Company */} +
+ {showBack && ( + + )} +
router.push("/pop/home")} + > +
+ + + +
+
+ {title ? ( + + {title} + + ) : ( + <> + + 탑씰 + + + 현장 관리 시스템 + + + )} +
+
+
+ + {/* Center: Clock (desktop) */} +
+ {mounted && ( + <> +
+ {hours} + + : + + {minutes} + + : + + {seconds} +
+ {dateStr} + + )} +
+ + {/* Right: Mobile clock + Profile */} +
+ {/* Mobile clock */} + {mounted && ( +
+ + + + + {hours}:{minutes} + +
+ )} + + {/* Custom header right content (e.g. cart icon) */} + {headerRight} + +
+ + {/* Profile with Dropdown */} +
+ + + {/* Profile Dropdown */} +
+ {/* User Info */} +
+

김철수

+

생산1팀

+
+ + {/* Menu Items */} +
+ + + +
+ + {/* Logout */} +
+ +
+
+
+
+
+ + {/* ===== NOTICE BANNER (Marquee) ===== */} + {showBanner &&
+
+ 📢 + 공지 +
+
+
+ {marqueeText} +
+
+
} + + {/* ===== MAIN CONTENT ===== */} +
+ {children} +
+ + {/* ===== FOOTER ===== */} +
+
+ © 2026 탑씰. All rights reserved. +
+ Version 1.0.0 + | + 긴급연락: 042-XXX-XXXX +
+
+
+ + {/* Marquee keyframes */} + +
+ ); +} diff --git a/frontend/components/pop/hardcoded/RecentActivity.tsx b/frontend/components/pop/hardcoded/RecentActivity.tsx new file mode 100644 index 00000000..8ada9c51 --- /dev/null +++ b/frontend/components/pop/hardcoded/RecentActivity.tsx @@ -0,0 +1,176 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { apiClient } from "@/lib/api/client"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface ActivityItem { + id: string; + time: string; + title: string; + description: string; + iconGradient: string; + icon: React.ReactNode; +} + +/* ------------------------------------------------------------------ */ +/* Icons */ +/* ------------------------------------------------------------------ */ + +const InboundIcon = ( + + + +); + +const OutboundIcon = ( + + + +); + +/* ------------------------------------------------------------------ */ +/* Helper */ +/* ------------------------------------------------------------------ */ + +function formatTime(dateStr: string): string { + if (!dateStr) return ""; + try { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return ""; + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + } catch { + return ""; + } +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function RecentActivity() { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchActivity = async () => { + try { + const [inboundRes, outboundRes] = await Promise.all([ + apiClient.get("/receiving/list", { + params: {}, + }), + apiClient.get("/outbound/list", { + params: {}, + }), + ]); + + const inboundData: Record[] = inboundRes.data?.data ?? []; + const outboundData: Record[] = outboundRes.data?.data ?? []; + + // Map inbound items + const inboundItems: ActivityItem[] = inboundData.slice(0, 10).map((r, idx) => ({ + id: `in-${r.id || idx}`, + time: formatTime(String(r.created_date ?? "")), + title: `입고 ${String(r.inbound_number ?? "")}`, + description: [ + r.supplier_name ? String(r.supplier_name) : null, + r.item_name ? String(r.item_name) : null, + r.inbound_qty ? `${String(r.inbound_qty)}${r.unit ? String(r.unit) : "EA"}` : null, + ].filter(Boolean).join(" | ") || "입고 처리", + iconGradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)", + icon: InboundIcon, + })); + + // Map outbound items + const outboundItems: ActivityItem[] = outboundData.slice(0, 10).map((r, idx) => ({ + id: `out-${r.id || idx}`, + time: formatTime(String(r.created_date ?? "")), + title: `출고 ${String(r.outbound_number ?? "")}`, + description: [ + r.customer_name ? String(r.customer_name) : null, + r.item_name ? String(r.item_name) : null, + r.outbound_qty ? `${String(r.outbound_qty)}${r.unit ? String(r.unit) : "EA"}` : null, + ].filter(Boolean).join(" | ") || "출고 처리", + iconGradient: "linear-gradient(135deg,#22c55e,#15803d)", + icon: OutboundIcon, + })); + + // Merge and sort by time descending, take top 5 + const merged = [...inboundItems, ...outboundItems].sort((a, b) => { + // Sort by time string descending (HH:MM) + if (b.time > a.time) return 1; + if (b.time < a.time) return -1; + return 0; + }); + + setActivities(merged.slice(0, 5)); + } catch { + // On failure, show empty state + setActivities([]); + } finally { + setLoading(false); + } + }; + + fetchActivity(); + }, []); + + return ( +
+
+
+

최근 활동

+ + 전체 보기 + +
+ + {loading ? ( +
+ + + + + 불러오는 중... +
+ ) : activities.length === 0 ? ( +
+ + + +

최근 활동이 없습니다

+
+ ) : ( +
+ {activities.map((item) => ( +
+ + {item.time} + +
+ {item.icon} +
+
+
{item.title}
+
{item.description}
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/components/pop/hardcoded/common/BarcodeScanModal.tsx b/frontend/components/pop/hardcoded/common/BarcodeScanModal.tsx new file mode 100644 index 00000000..f4d1f4bc --- /dev/null +++ b/frontend/components/pop/hardcoded/common/BarcodeScanModal.tsx @@ -0,0 +1,371 @@ +"use client"; + +import React, { useState, useRef, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react"; +import Webcam from "react-webcam"; +import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library"; + +export interface BarcodeScanModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + targetField?: string; + barcodeFormat?: "all" | "1d" | "2d"; + autoSubmit?: boolean; + onScanSuccess: (barcode: string) => void; + userId?: string; +} + +export const BarcodeScanModal: React.FC = ({ + open, + onOpenChange, + targetField, + barcodeFormat = "all", + autoSubmit = false, + onScanSuccess, + userId = "guest", +}) => { + const [isScanning, setIsScanning] = useState(false); + const [scannedCode, setScannedCode] = useState(""); + const [error, setError] = useState(""); + const [hasPermission, setHasPermission] = useState(null); + const webcamRef = useRef(null); + const codeReaderRef = useRef(null); + const scanIntervalRef = useRef(null); + + // 바코드 리더 초기화 + 모달 열릴 때 상태 리셋 + useEffect(() => { + if (open) { + setScannedCode(""); + setError(""); + setIsScanning(false); + codeReaderRef.current = new BrowserMultiFormatReader(); + } + + return () => { + stopScanning(); + if (codeReaderRef.current) { + codeReaderRef.current.reset(); + } + }; + }, [open]); + + // 카메라 권한 요청 + const requestCameraPermission = async () => { + // navigator.mediaDevices 지원 확인 + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setHasPermission(false); + setError( + "이 브라우저는 카메라 접근을 지원하지 않거나, 보안 컨텍스트(HTTPS 또는 localhost)가 아닙니다. " + + "현재 프로토콜: " + window.location.protocol + ); + toast.error("카메라 접근이 불가능합니다."); + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + setHasPermission(true); + stream.getTracks().forEach((track) => track.stop()); + toast.success("카메라 권한이 허용되었습니다."); + } catch (err: any) { + setHasPermission(false); + + if (err.name === "NotAllowedError") { + setError("카메라 접근이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요."); + toast.error("카메라 권한이 거부되었습니다."); + } else if (err.name === "NotFoundError") { + setError("카메라를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요."); + toast.error("카메라를 찾을 수 없습니다."); + } else if (err.name === "NotReadableError") { + setError("카메라가 이미 다른 애플리케이션에서 사용 중입니다."); + toast.error("카메라가 사용 중입니다."); + } else if (err.name === "NotSupportedError") { + setError("보안 컨텍스트(HTTPS 또는 localhost)가 아니어서 카메라를 사용할 수 없습니다."); + toast.error("HTTPS 환경이 필요합니다."); + } else { + setError(`카메라 접근 오류: ${err.name} - ${err.message}`); + toast.error("카메라 접근 중 오류가 발생했습니다."); + } + } + }; + + // 스캔 시작 + const startScanning = () => { + setIsScanning(true); + setError(""); + setScannedCode(""); + + scanIntervalRef.current = setInterval(() => { + scanBarcode(); + }, 500); + }; + + // 스캔 중지 + const stopScanning = () => { + setIsScanning(false); + if (scanIntervalRef.current) { + clearInterval(scanIntervalRef.current); + scanIntervalRef.current = null; + } + }; + + // 바코드 스캔 + const scanBarcode = async () => { + if (!webcamRef.current || !codeReaderRef.current) return; + + try { + const imageSrc = webcamRef.current.getScreenshot(); + if (!imageSrc) return; + + const img = new Image(); + img.src = imageSrc; + + await new Promise((resolve) => { + img.onload = resolve; + }); + + const result = await codeReaderRef.current.decodeFromImageElement(img); + + if (result) { + const barcode = result.getText(); + + setScannedCode(barcode); + stopScanning(); + toast.success(`바코드 스캔 완료: ${barcode}`); + + if (autoSubmit) { + onScanSuccess(barcode); + } + } + } catch (err) { + if (!(err instanceof NotFoundException)) { + // NotFoundException은 정상 (바코드 미인식) + } + } + }; + + // 수동 확인 버튼 + const handleConfirm = () => { + if (scannedCode) { + onScanSuccess(scannedCode); + } else { + toast.error("스캔된 바코드가 없습니다."); + } + }; + + return ( + + + + 바코드 스캔 + + 카메라로 바코드를 스캔합니다. + {targetField && ` (대상 필드: ${targetField})`} + + + +
+ {/* 카메라 권한 요청 대기 중 */} + {hasPermission === null && ( +
+
+ +
+
+

카메라 권한이 필요합니다

+

+ 바코드를 스캔하려면 카메라 접근 권한을 허용해주세요. +

+
+ +
+

권한 요청 안내:

+
    +
  • 아래 버튼을 클릭하면 브라우저에서 권한 요청 팝업이 표시됩니다
  • +
  • 팝업에서 "허용" 버튼을 클릭해주세요
  • +
  • 권한은 언제든지 브라우저 설정에서 변경할 수 있습니다
  • +
+
+ +
+ +
+
+
+
+ )} + + {/* 카메라 권한 거부됨 */} + {hasPermission === false && ( +
+
+ +
+
+

카메라 접근 권한이 필요합니다

+

{error}

+
+ +
+

권한 허용 방법:

+
    +
  1. 브라우저 주소창 왼쪽의 자물쇠 아이콘을 클릭하세요
  2. +
  3. "카메라" 항목을 찾아 "허용"으로 변경하세요
  4. +
  5. 페이지를 새로고침하거나 다시 스캔을 시도하세요
  6. +
+
+ +
+ +
+
+
+
+ )} + + {/* 웹캠 뷰 */} + {hasPermission && ( +
+ + + {/* 스캔 가이드 오버레이 */} + {isScanning && ( +
+
+
+
+ + 스캔 중... +
+
+
+ )} + + {/* 스캔 완료 오버레이 */} + {scannedCode && ( +
+
+ +

스캔 완료!

+

{scannedCode}

+
+
+ )} +
+ )} + + {/* 바코드 포맷 정보 */} +
+
+ +
+

지원 포맷

+

+ {barcodeFormat === "all" && "1D/2D 바코드 모두 지원 (Code 128, QR Code 등)"} + {barcodeFormat === "1d" && "1D 바코드 (Code 128, Code 39, EAN-13, UPC-A)"} + {barcodeFormat === "2d" && "2D 바코드 (QR Code, Data Matrix)"} +

+
+
+
+ + {/* 에러 메시지 */} + {error && ( +
+
+ +

{error}

+
+
+ )} +
+ + + + + {!isScanning && !scannedCode && hasPermission && ( + + )} + + {isScanning && ( + + )} + + {scannedCode && ( + + )} + + {scannedCode && !autoSubmit && ( + + )} + + +
+ ); +}; diff --git a/frontend/components/pop/hardcoded/common/useCartSync.ts b/frontend/components/pop/hardcoded/common/useCartSync.ts new file mode 100644 index 00000000..5879c49b --- /dev/null +++ b/frontend/components/pop/hardcoded/common/useCartSync.ts @@ -0,0 +1,26 @@ +/** + * useCartSync - 장바구니 DB 동기화 훅 (hardcoded 컴포넌트용 re-export) + * + * 실제 구현은 @/hooks/pop/useCartSync 에 있고, + * 여기서는 hardcoded 입고 컴포넌트들이 쉽게 import할 수 있도록 re-export한다. + * + * 사용법: + * ```typescript + * import { useCartSync } from "../common/useCartSync"; + * const cart = useCartSync("pop-purchase-inbound", "purchase_detail"); + * ``` + */ + +export { useCartSync } from "@/hooks/pop/useCartSync"; +export type { + UseCartSyncReturn, + CartChanges, +} from "@/hooks/pop/useCartSync"; + +// 타입도 함께 re-export (hardcoded 컴포넌트에서 필요할 수 있음) +export type { + CartItem, + CartItemWithId, + CartSyncStatus, + CartItemStatus, +} from "@/lib/registry/pop-components/types"; diff --git a/frontend/components/pop/hardcoded/inbound/InboundCart.tsx b/frontend/components/pop/hardcoded/inbound/InboundCart.tsx new file mode 100644 index 00000000..36efb141 --- /dev/null +++ b/frontend/components/pop/hardcoded/inbound/InboundCart.tsx @@ -0,0 +1,528 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; +import { InspectionModal, type InspectionResult } from "./InspectionModal"; +import type { PackageEntry } from "./NumberPadModal"; + +/* ------------------------------------------------------------------ */ +/* Warehouse type */ +/* ------------------------------------------------------------------ */ + +interface Warehouse { + warehouse_code: string; + warehouse_name: string; + warehouse_type?: string; +} + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface CartItem { + id: string; + /** cart_items 테이블의 PK (UUID) — DB 삭제용 */ + dbId?: string; + /** purchase_detail or purchase_order_mng */ + source_table: string; + /** PK of the source row */ + source_id: string; + purchase_no: string; + item_code: string; + item_name: string; + spec: string; + material: string; + order_qty: number; + remain_qty: number; + /** User-entered quantity */ + inbound_qty: number; + unit_price: number; + supplier_code: string; + supplier_name: string; + order_date: string; + inspection_required?: boolean; + inspection_type?: "self" | "request" | null; + packages?: PackageEntry[]; + inspectionResult?: InspectionResult | null; +} + +interface InboundCartProps { + open: boolean; + onClose: () => void; + items: CartItem[]; + onUpdateQty: (id: string, qty: number) => void; + onRemove: (id: string) => void; + onClear: () => void; + supplierName?: string; + onUpdateItems?: (items: CartItem[]) => void; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function InboundCart({ + open, + onClose, + items, + onUpdateQty, + onRemove, + onClear, + supplierName, + onUpdateItems, +}: InboundCartProps) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [resultMsg, setResultMsg] = useState(null); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [inspectionTarget, setInspectionTarget] = useState(null); + + /* Warehouse state */ + const [warehouses, setWarehouses] = useState([]); + const [selectedWarehouse, setSelectedWarehouse] = useState(""); + + /* Fetch warehouses on mount */ + const fetchWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/receiving/warehouses"); + const data: Warehouse[] = res.data?.data ?? []; + setWarehouses(data); + if (data.length > 0 && !selectedWarehouse) { + setSelectedWarehouse(data[0].warehouse_code); + } + } catch { + // Keep empty - user can still confirm without warehouse + } + }, [selectedWarehouse]); + + useEffect(() => { + if (open) { + fetchWarehouses(); + } + }, [open, fetchWarehouses]); + + const totalQty = items.reduce((s, i) => s + i.inbound_qty, 0); + const totalAmount = items.reduce((s, i) => s + i.inbound_qty * i.unit_price, 0); + + /* Toggle select */ + const toggleSelect = (id: string) => { + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedItems.size === items.length) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }; + + /* Open inspection modal */ + const openInspection = (item: CartItem) => { + setInspectionTarget(item); + setInspectionModalOpen(true); + }; + + /* Handle inspection complete */ + const handleInspectionComplete = (result: InspectionResult) => { + if (!inspectionTarget || !onUpdateItems) return; + const updated = items.map((item) => + item.id === inspectionTarget.id + ? { ...item, inspectionResult: result } + : item + ); + onUpdateItems(updated); + setInspectionTarget(null); + }; + + /* Confirm inbound — PC receivingController.create 와 동일한 body 구조 */ + const handleConfirm = async () => { + if (items.length === 0) return; + if (!selectedWarehouse) { + setResultMsg("오류: 입고 창고를 선택해주세요."); + return; + } + setConfirming(true); + setResultMsg(null); + + try { + // 1. 입고번호 채번 (RCV-YYYY-XXXX) + let inboundNumber: string | undefined; + try { + const numRes = await apiClient.get("/receiving/generate-number"); + if (numRes.data?.success && numRes.data?.data) { + inboundNumber = numRes.data.data; + } + } catch { + // 채번 실패 시 백엔드가 처리 + } + + // 2. POST /api/receiving — PC create 와 동일한 payload + const payload = { + inbound_number: inboundNumber, + inbound_date: new Date().toISOString().slice(0, 10), + warehouse_code: selectedWarehouse, + inbound_type: "구매입고", + items: items.map((item, idx) => ({ + inbound_type: "구매입고", + item_number: item.item_code, + item_name: item.item_name, + spec: item.spec || "", + material: item.material || "", + unit: "EA", + inbound_qty: String(item.inbound_qty), + unit_price: String(item.unit_price || 0), + total_amount: String((item.inbound_qty || 0) * (item.unit_price || 0)), + reference_number: item.purchase_no, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + inspection_status: item.inspectionResult?.completed + ? "검사완료" + : item.inspection_required + ? "검사대기" + : "합격", + source_table: item.source_table, + source_id: item.source_id || item.id, + seq_no: idx + 1, + })), + }; + + const res = await apiClient.post("/receiving", payload); + + if (res.data?.success) { + // 3. cart_items DB 정리 (백그라운드, 논블로킹) + // cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨) + const rowKeys = items.map((item) => item.source_id || item.id).filter(Boolean); + if (rowKeys.length > 0) { + apiClient.post("/pop/execute-action", { + tasks: [{ type: "cart-save" }], + cartChanges: { + toDelete: rowKeys, + }, + }).catch(() => { + // cart cleanup 실패 시 무시 + }); + } + + const inboundNo = res.data?.data?.header?.inbound_number || inboundNumber || ""; + setResultMsg(`${items.length}건 입고 등록 완료! (${inboundNo})`); + setTimeout(() => { + onClear(); + onClose(); + router.push("/pop/inbound"); + }, 1500); + } else { + setResultMsg(`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "입고 등록에 실패했습니다."; + setResultMsg(`오류: ${msg}`); + } finally { + setConfirming(false); + } + }; + + if (!open) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+
+ + + +
+
+

입고 장바구니

+ {supplierName && ( +

{supplierName}

+ )} +
+
+ +
+ + {/* Select all bar */} + {items.length > 0 && ( +
+ + + 전체 선택 ({selectedItems.size}/{items.length}) + +
+ )} + + {/* Items */} +
+ {items.length === 0 ? ( +
+ + + +

담은 품목이 없습니다

+
+ ) : ( +
+ {items.map((item) => ( +
+ {/* Top row: checkbox + name + delete */} +
+ {/* Checkbox */} + + +
+

{item.item_name}

+

+ {item.item_code} | {item.purchase_no} +

+
+ + {/* Delete button */} + +
+ + {/* Spec row */} + {(item.spec || item.material) && ( +

+ {[item.spec, item.material].filter(Boolean).join(" | ")} +

+ )} + + {/* Package info */} + {item.packages && item.packages.length > 0 && ( +
+
+ + 포장완료 + + + {"\uD83D\uDCE6"} {item.packages.map(p => + `${p.count}${p.unit.label} x ${p.qtyPerUnit.toLocaleString()} = ${(p.count * p.qtyPerUnit).toLocaleString()}EA` + ).join(", ")} + +
+
+ )} + + {/* Inspection row */} + {(item.inspection_type === "self" || item.inspection_type === "request") && ( +
+ +
+ )} + + {/* Qty controls */} +
+
+ 미입고: {item.remain_qty.toLocaleString()} +
+
+ + { + const v = parseInt(e.target.value, 10); + if (!isNaN(v) && v >= 0) onUpdateQty(item.id, Math.min(v, item.remain_qty)); + }} + className="w-16 h-8 text-center text-sm font-semibold border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100" + style={{ fontVariantNumeric: "tabular-nums" }} + /> + +
+
+
+ ))} +
+ )} +
+ + {/* Footer summary + confirm */} + {items.length > 0 && ( +
+ {/* Result message */} + {resultMsg && ( +
+ {resultMsg} +
+ )} + + {/* Warehouse selection */} +
+ + +
+ + {/* Summary */} +
+ + 총 {items.length}건 + +
+ + 합계 수량: {totalQty.toLocaleString()} + + {totalAmount > 0 && ( + + ({totalAmount.toLocaleString()}원) + + )} +
+
+ + {/* Buttons */} +
+ + +
+
+ )} +
+ + {/* Inspection Modal */} + {inspectionTarget && ( + { setInspectionModalOpen(false); setInspectionTarget(null); }} + onComplete={handleInspectionComplete} + itemCode={inspectionTarget.item_code} + itemName={inspectionTarget.item_name} + totalQty={inspectionTarget.inbound_qty} + initialResult={inspectionTarget.inspectionResult} + /> + )} +
+ ); +} diff --git a/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx b/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx new file mode 100644 index 00000000..960794b4 --- /dev/null +++ b/frontend/components/pop/hardcoded/inbound/InboundCartPage.tsx @@ -0,0 +1,1110 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; +import { InspectionModal, type InspectionResult } from "./InspectionModal"; +import { NumberPadModal, type PackageEntry } from "./NumberPadModal"; +import { useCartSync, type CartItemWithId } from "../common/useCartSync"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface Warehouse { + warehouse_code: string; + warehouse_name: string; + warehouse_type?: string; +} + +/** CartItemWithId -> 화면 표시용 파싱 결과 */ +interface CartItemParsed { + id: string; + rowKey: string; + dbId: string; + source_table: string; + source_id: string; + purchase_no: string; + item_code: string; + item_name: string; + spec: string; + material: string; + order_qty: number; + remain_qty: number; + inbound_qty: number; + unit_price: number; + supplier_code: string; + supplier_name: string; + order_date?: string; + inspection_required?: boolean; + inspection_type?: "self" | "request" | null; + packages?: PackageEntry[]; + image?: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Helper: CartItemWithId -> CartItemParsed */ +/* ------------------------------------------------------------------ */ +function toCartItemParsed(item: CartItemWithId): CartItemParsed { + const data = item.row; + const inspType = data.inspection_type === "self" ? "self" + : data.inspection_type === "request" ? "request" + : null; + + return { + id: item.rowKey || String(data.id ?? ""), + rowKey: item.rowKey, + dbId: item.cartId || "", + source_table: item.sourceTable || String(data.source_table ?? "purchase_detail"), + source_id: item.rowKey || String(data.id ?? ""), + purchase_no: String(data.purchase_no ?? ""), + item_code: String(data.item_code ?? ""), + item_name: String(data.item_name ?? ""), + spec: String(data.spec ?? ""), + material: String(data.material ?? ""), + order_qty: Number(data.order_qty ?? 0), + remain_qty: Number(data.remain_qty ?? 0), + inbound_qty: item.quantity, + unit_price: Number(data.unit_price ?? 0), + supplier_code: String(data.supplier_code ?? ""), + supplier_name: String(data.supplier_name ?? ""), + order_date: data.order_date ? String(data.order_date) : undefined, + inspection_type: inspType, + inspection_required: inspType === "self", + // packageEntries의 실제 런타임 타입은 NumberPadModal의 PackageEntry[] + packages: item.packageEntries as unknown as PackageEntry[] | undefined, + image: data.image ? String(data.image) : null, + }; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function InboundCartPage() { + const router = useRouter(); + + /* Cart sync hook */ + const cart = useCartSync("pop-purchase-inbound", "purchase_detail"); + + /* Derived: parsed items from cart */ + const items = useMemo( + () => cart.cartItems.map(toCartItemParsed), + [cart.cartItems], + ); + + /* Inspection results (local overlay, keyed by rowKey) */ + const [inspectionResults, setInspectionResults] = useState< + Map + >(new Map()); + + /* Selection */ + const [selectedItems, setSelectedItems] = useState>(new Set()); + + /* Auto-select all when items change */ + useEffect(() => { + if (items.length > 0) { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }, [items]); + + /* Warehouse */ + const [warehouses, setWarehouses] = useState([]); + const [selectedWarehouse, setSelectedWarehouse] = useState(""); + const [warehousePickerOpen, setWarehousePickerOpen] = useState(false); + + /* Inbound number */ + const [inboundNumber, setInboundNumber] = useState(""); + + /* Confirm result modal */ + const [confirmResult, setConfirmResult] = useState<{ + inboundNumber: string; + items: CartItemParsed[]; + warehouse: string; + date: string; + } | null>(null); + + /* Inbound date */ + const [inboundDate, setInboundDate] = useState( + new Date().toISOString().slice(0, 10) + ); + + /* Confirm state */ + const [confirming, setConfirming] = useState(false); + const [resultMsg, setResultMsg] = useState(null); + + /* Inspection modal */ + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [inspectionTarget, setInspectionTarget] = useState(null); + + /* Numpad modal (for qty edit) */ + const [numpadOpen, setNumpadOpen] = useState(false); + const [numpadTarget, setNumpadTarget] = useState(null); + + /* Derived: supplier name (all items should be same supplier) */ + const supplierName = items.length > 0 ? items[0].supplier_name : ""; + + /* ------------------------------------------------------------------ */ + /* Fetch warehouses */ + /* ------------------------------------------------------------------ */ + const fetchedRef = useRef(false); + + const fetchWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/receiving/warehouses"); + const data: Warehouse[] = res.data?.data ?? []; + setWarehouses(data); + if (data.length > 0) { + setSelectedWarehouse(data[0].warehouse_code); + } + } catch { + /* keep empty */ + } + }, []); + + useEffect(() => { + if (fetchedRef.current) return; + fetchedRef.current = true; + fetchWarehouses(); + }, [fetchWarehouses]); + + /* ------------------------------------------------------------------ */ + /* Selection */ + /* ------------------------------------------------------------------ */ + const toggleSelect = (id: string) => { + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedItems.size === items.length) { + setSelectedItems(new Set()); + } else { + setSelectedItems(new Set(items.map((i) => i.id))); + } + }; + + /* ------------------------------------------------------------------ */ + /* Qty edit via numpad */ + /* ------------------------------------------------------------------ */ + const openNumpad = (item: CartItemParsed) => { + setNumpadTarget(item); + setNumpadOpen(true); + }; + + const handleNumpadConfirm = (qty: number, packages: PackageEntry[]) => { + if (!numpadTarget) return; + const finalQty = Math.min(qty, numpadTarget.remain_qty); + + cart.updateItemQuantity( + numpadTarget.rowKey, + finalQty, + undefined, + // PackageEntry 타입이 registry vs NumberPadModal에서 다르므로 any 캐스팅 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + packages.length > 0 ? packages as any : undefined, + ); + setNumpadTarget(null); + // Auto-save effect below will persist change to DB + }; + + /* ------------------------------------------------------------------ */ + /* Remove item */ + /* ------------------------------------------------------------------ */ + const handleRemove = (rowKey: string) => { + cart.removeItem(rowKey); + setSelectedItems((prev) => { + const next = new Set(prev); + next.delete(rowKey); + return next; + }); + // Auto-save effect below will persist change to DB + }; + + /* Auto-save: persist dirty changes to DB after a short debounce */ + const autoSaveTimerRef = useRef | null>(null); + useEffect(() => { + if (!cart.isDirty || cart.syncStatus === "saving") return; + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = setTimeout(() => { + cart.saveToDb().catch(() => {}); + }, 500); + return () => { + if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); + }; + }, [cart.isDirty, cart.syncStatus, cart]); + + /* ------------------------------------------------------------------ */ + /* Inspection */ + /* ------------------------------------------------------------------ */ + const openInspection = (item: CartItemParsed) => { + setInspectionTarget(item); + setInspectionModalOpen(true); + }; + + const handleInspectionComplete = (result: InspectionResult) => { + if (!inspectionTarget) return; + setInspectionResults((prev) => { + const next = new Map(prev); + next.set(inspectionTarget.rowKey, result); + return next; + }); + setInspectionTarget(null); + }; + + /* Pass inspection (non-required only) */ + const handlePassInspection = (rowKey: string) => { + const item = items.find((i) => i.rowKey === rowKey); + if (!item) return; + setInspectionResults((prev) => { + const next = new Map(prev); + next.set(rowKey, { + items: [], + goodQty: item.inbound_qty, + badQty: 0, + remark: "pass", + completed: true, + }); + return next; + }); + }; + + const getInspectionResult = (rowKey: string): InspectionResult | null => { + return inspectionResults.get(rowKey) || null; + }; + + /* ------------------------------------------------------------------ */ + /* Validation: required inspections */ + /* ------------------------------------------------------------------ */ + const selectedItemsList = items.filter((i) => selectedItems.has(i.id)); + + const hasUnfinishedRequiredInspection = selectedItemsList.some( + (item) => + item.inspection_required && + item.inspection_type === "self" && + !getInspectionResult(item.rowKey)?.completed + ); + + /* ------------------------------------------------------------------ */ + /* Confirm inbound */ + /* ------------------------------------------------------------------ */ + const handleConfirm = async () => { + if (selectedItemsList.length === 0) return; + + if (!selectedWarehouse) { + setResultMsg("오류: 입고 창고를 선택해주세요."); + return; + } + + if (hasUnfinishedRequiredInspection) { + setResultMsg("오류: 필수 검사를 완료해주세요."); + return; + } + + setConfirming(true); + setResultMsg(null); + + try { + // 확정 시점에 채번 (동시접속 충돌 방지) + let finalNumber = ""; + try { + const numRes = await apiClient.get("/receiving/generate-number"); + if (numRes.data?.success && numRes.data?.data) { + finalNumber = numRes.data.data; + setInboundNumber(finalNumber); + } + } catch { + /* backend will handle */ + } + + // POST /api/receiving -- same payload structure as PC + const payload = { + inbound_number: finalNumber, + inbound_date: inboundDate, + warehouse_code: selectedWarehouse, + inbound_type: "구매입고", + items: selectedItemsList.map((item, idx) => { + const inspResult = getInspectionResult(item.rowKey); + return { + inbound_type: "구매입고", + item_number: item.item_code, + item_name: item.item_name, + spec: item.spec || "", + material: item.material || "", + unit: "EA", + inbound_qty: String(item.inbound_qty), + unit_price: String(item.unit_price || 0), + total_amount: String( + (item.inbound_qty || 0) * (item.unit_price || 0) + ), + reference_number: item.purchase_no, + supplier_code: item.supplier_code, + supplier_name: item.supplier_name, + inspection_status: inspResult?.completed + ? "검사완료" + : item.inspection_required + ? "검사대기" + : "합격", + source_table: item.source_table, + source_id: item.source_id || item.id, + seq_no: idx + 1, + }; + }), + }; + + const res = await apiClient.post("/receiving", payload); + + if (res.data?.success) { + // Remove confirmed items from cart - direct DB delete for reliability + const confirmedItems = [...selectedItemsList]; + const { dataApi } = await import("@/lib/api/data"); + const confirmPromises = confirmedItems + .filter((item) => item.dbId) + .map((item) => dataApi.updateRecord("cart_items", item.dbId, { status: "confirmed" }).catch(() => {})); + await Promise.all(confirmPromises); + + // Also clean up local state via useCartSync + for (const item of confirmedItems) { + cart.removeItem(item.rowKey); + } + // Reload from DB to sync state + await cart.loadFromDb(); + + const inboundNo = + res.data?.data?.header?.inbound_number || finalNumber || ""; + + // 결과 모달 표시 (바로 이동하지 않음) + setConfirmResult({ + inboundNumber: inboundNo, + items: confirmedItems, + warehouse: warehouses.find(w => w.warehouse_code === selectedWarehouse)?.warehouse_name || selectedWarehouse, + date: inboundDate, + }); + setResultMsg(null); + } else { + setResultMsg( + `오류: ${res.data?.message || "입고 등록에 실패했습니다."}` + ); + } + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "입고 등록에 실패했습니다."; + setResultMsg(`오류: ${msg}`); + } finally { + setConfirming(false); + } + }; + + /* ------------------------------------------------------------------ */ + /* Helpers */ + /* ------------------------------------------------------------------ */ + const selectedWarehouseName = + warehouses.find((w) => w.warehouse_code === selectedWarehouse) + ?.warehouse_name || selectedWarehouse; + + const totalQty = selectedItemsList.reduce((s, i) => s + i.inbound_qty, 0); + + /* ------------------------------------------------------------------ */ + /* Render */ + /* ------------------------------------------------------------------ */ + return ( +
+ {/* ===== Header ===== */} +
+
+ +
+

+ 입고 장바구니 +

+ {supplierName && ( +

{supplierName}

+ )} +
+
+ + {/* Confirm button (header only) */} + +
+ + {/* ===== Info banner ===== */} +
+
+ {supplierName && ( + + {supplierName} + + )} + + {inboundDate} + + {selectedWarehouseName && ( + + | {selectedWarehouseName} + + )} + + {inboundNumber || "확정 시 자동생성"} + +
+ + {/* Info fields: 3 columns */} +
+ {/* Inbound date */} +
+ + setInboundDate(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100 bg-white" + /> +
+ + {/* Warehouse selector - card-style touch button */} +
+ + +
+ + {/* Inbound number (readonly -- 확정 시점에 채번) */} +
+ +
+ {inboundNumber ? ( + {inboundNumber} + ) : ( + 확정 시 자동생성 + )} +
+
+
+
+ + {/* ===== Select all bar ===== */} + {items.length > 0 && ( +
+
+ + + 담은 품목{" "} + {items.length} + +
+ + +
+ )} + + {/* ===== Items list ===== */} + {cart.loading ? ( +
+ + + + + 불러오는 중... +
+ ) : items.length === 0 ? ( +
+ + + +

+ 담은 품목이 없습니다 +

+

+ 구매입고 화면에서 품목을 담아주세요 +

+ +
+ ) : ( +
+ {items.map((item) => { + const inspResult = getInspectionResult(item.rowKey); + return ( +
+ {/* Blue left bar for selected items */} + {selectedItems.has(item.id) && ( +
+ )} + + {/* === Header row: checkbox + item code + item name + inspection badge === */} +
+ {/* Checkbox */} + + {item.item_code} + {item.item_name} + {item.inspection_type === "self" && ( + + 검사 필수 + + )} + {item.inspection_type === "request" && ( + + 검사의뢰 선택 + + )} +
+ + {/* === Body row: image + info + action === */} +
+ {/* Product image */} +
+ {item.image ? ( + {item.item_name} + ) : ( + {"\uD83D\uDCE6"} + )} +
+ + {/* Info columns */} +
+
+ 발주일 + {item.order_date || "-"} +
+
+ 발주번호 + {item.purchase_no || "-"} +
+
+ 발주수량 + {item.order_qty.toLocaleString()} +
+
+ 미입고 + {item.remain_qty.toLocaleString()} +
+
+ + {/* Action column: qty display + delete button */} +
+ {/* Qty display - clickable to open numpad */} + + + {/* Delete button */} + +
+
+ + {/* === Package info === */} + {item.packages && item.packages.length > 0 && ( +
+
+ + 포장완료 + + + {item.packages.reduce((s, p) => s + p.count * p.qtyPerUnit, 0).toLocaleString()} EA + +
+ {item.packages.map((pkg, idx) => ( +
+ {pkg.unit.icon} + {pkg.count}{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA = {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA +
+ ))} +
+ )} + + {/* === Inspection row === */} + {(item.inspection_type === "self" || + item.inspection_type === "request") && ( +
+
+ + + {/* Pass button for non-required */} + {!item.inspection_required && + !inspResult?.completed && ( + + )} +
+
+ )} +
+ ); + })} +
+ )} + + {/* ===== Footer summary (no confirm button -- header only) ===== */} + {items.length > 0 && ( +
+ {/* Result message */} + {resultMsg && ( +
+ {resultMsg} +
+ )} + + {/* Required inspection warning */} + {hasUnfinishedRequiredInspection && ( +
+ 필수 검사를 완료해주세요. 검사 미완료 품목이 있어 확정할 수 없습니다. +
+ )} + + {/* Summary only (no big confirm button) */} +
+ + 선택{" "} + + {selectedItemsList.length} + + /{items.length}건 + + + 합계 수량:{" "} + + {totalQty.toLocaleString()} + {" "} + EA + +
+
+ )} + + {/* ===== Warehouse picker modal ===== */} + {warehousePickerOpen && ( +
+
setWarehousePickerOpen(false)} + /> +
+ {/* Header */} +
+

창고 선택

+ +
+ + {/* Warehouse list */} +
+ {warehouses.length === 0 ? ( +

+ 등록된 창고가 없습니다 +

+ ) : ( +
+ {warehouses.map((wh) => ( + + ))} +
+ )} +
+
+
+ )} + + {/* ===== Inspection Modal ===== */} + {inspectionTarget && ( + { + setInspectionModalOpen(false); + setInspectionTarget(null); + }} + onComplete={handleInspectionComplete} + itemCode={inspectionTarget.item_code} + itemName={inspectionTarget.item_name} + totalQty={inspectionTarget.inbound_qty} + initialResult={getInspectionResult(inspectionTarget.rowKey)} + /> + )} + + {/* ===== NumberPad Modal (qty edit) ===== */} + {numpadTarget && ( + { + setNumpadOpen(false); + setNumpadTarget(null); + }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget.remain_qty} + itemName={numpadTarget.item_name} + initialQty={numpadTarget.inbound_qty} + initialPackages={numpadTarget.packages} + /> + )} + + {/* ===== 입고 완료 결과 모달 ===== */} + {confirmResult && ( +
+
+
+ {/* 헤더 */} +
+
+ + + +
+

입고 처리 완료

+

{confirmResult.inboundNumber}

+
+ + {/* 처리 내역 */} +
+
+ 창고: {confirmResult.warehouse} + {confirmResult.date} +
+ +
처리된 품목 ({confirmResult.items.length}건)
+
+ {confirmResult.items.map((item) => ( +
+
+

{item.item_name}

+

{item.item_code}

+
+ {item.inbound_qty?.toLocaleString()} EA +
+ ))} +
+
+ + {/* 확인 버튼 */} +
+ +
+
+
+ )} +
+ ); +} diff --git a/frontend/components/pop/hardcoded/inbound/InboundTypeSelect.tsx b/frontend/components/pop/hardcoded/inbound/InboundTypeSelect.tsx new file mode 100644 index 00000000..4a828c86 --- /dev/null +++ b/frontend/components/pop/hardcoded/inbound/InboundTypeSelect.tsx @@ -0,0 +1,558 @@ +"use client"; + +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface InboundMenuItem { + id: string; + title: string; + gradient: string; + shadowColor: string; + icon: React.ReactNode; + href: string; +} + +interface RecentInboundItem { + id: string; + time: string; + type: string; + itemName: string; + qty: string; + supplier: string; + statusColor: string; + statusLabel: string; +} + +interface KpiData { + todayTotal: number; + todayWaiting: number; + todayCompleted: number; + purchaseCount: number; + outsourcingCount: number; + otherCount: number; + todayQty: number; + todayQtyUnit: string; + defectCount: number; +} + +interface InboundRow { + id: number; + inbound_number: string; + inbound_type: string | null; + inbound_date: string | null; + inbound_status: string | null; + inbound_qty: number | string | null; + item_name: string | null; + item_number: string | null; + supplier_name: string | null; + unit: string | null; + created_date: string | null; + inspection_status: string | null; + detail_id: number | null; + warehouse_name: string | null; + reference_number: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Data */ +/* ------------------------------------------------------------------ */ + +const EXTERNAL_ITEMS: InboundMenuItem[] = [ + { + id: "purchase", + title: "구매입고", + gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)", + shadowColor: "rgba(59,130,246,.3)", + icon: ( + + + + ), + href: "/pop/inbound/purchase", + }, + { + id: "outsourcing", + title: "외주입고", + gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)", + shadowColor: "rgba(139,92,246,.3)", + icon: ( + + + + ), + href: "#", + }, + { + id: "return", + title: "반품입고", + gradient: "linear-gradient(135deg,#f59e0b,#d97706)", + shadowColor: "rgba(245,158,11,.3)", + icon: ( + + + + ), + href: "#", + }, + { + id: "supplied-material", + title: "사급자재", + gradient: "linear-gradient(135deg,#06b6d4,#0e7490)", + shadowColor: "rgba(6,182,212,.3)", + icon: ( + + + + ), + href: "#", + }, + { + id: "defect", + title: "불량입고", + gradient: "linear-gradient(135deg,#ef4444,#b91c1c)", + shadowColor: "rgba(239,68,68,.3)", + icon: ( + + + + ), + href: "#", + }, + { + id: "outsource-return", + title: "외주자재회수", + gradient: "linear-gradient(135deg,#ec4899,#be185d)", + shadowColor: "rgba(236,72,153,.3)", + icon: ( + + + + ), + href: "#", + }, + { + id: "exchange", + title: "교환입고", + gradient: "linear-gradient(135deg,#14b8a6,#0f766e)", + shadowColor: "rgba(20,184,166,.3)", + icon: ( + + + + ), + href: "#", + }, +]; + +const INTERNAL_ITEMS: InboundMenuItem[] = [ + { + id: "production", + title: "생산입고", + gradient: "linear-gradient(135deg,#22c55e,#15803d)", + shadowColor: "rgba(34,197,94,.3)", + icon: ( + + + + + ), + href: "#", + }, + { + id: "return-internal", + title: "반납입고", + gradient: "linear-gradient(135deg,#f97316,#c2410c)", + shadowColor: "rgba(249,115,22,.3)", + icon: ( + + + + ), + href: "#", + }, + { + id: "transfer", + title: "재고이동", + gradient: "linear-gradient(135deg,#64748b,#334155)", + shadowColor: "rgba(100,116,139,.3)", + icon: ( + + + + ), + href: "#", + }, +]; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function getStatusStyle(status: string | null): { color: string; label: string } { + switch (status) { + case "완료": + case "입고완료": + return { color: "text-green-600 bg-green-50", label: "완료" }; + case "검수중": + case "검수대기": + return { color: "text-blue-600 bg-blue-50", label: "검수중" }; + case "진행중": + case "입고중": + return { color: "text-amber-600 bg-amber-50", label: "진행중" }; + default: + return { color: "text-gray-600 bg-gray-50", label: status || "대기" }; + } +} + +function shortenType(type: string | null): string { + if (!type) return "기타"; + if (type.includes("구매")) return "구매입고"; + if (type.includes("외주")) return "외주입고"; + if (type.includes("생산")) return "생산입고"; + if (type.includes("반품")) return "반품입고"; + if (type.includes("반납")) return "반납입고"; + return type; +} + +function isCompleted(status: string | null): boolean { + return status === "완료" || status === "입고완료"; +} + +function isPurchase(type: string | null): boolean { + return !!type && type.includes("구매"); +} + +function isOutsourcing(type: string | null): boolean { + return !!type && type.includes("외주"); +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function InboundTypeSelect() { + const router = useRouter(); + + /* KPI carousel */ + const [kpiIdx, setKpiIdx] = useState(0); + const kpiTimerRef = useRef | null>(null); + + /* Data state */ + const [kpi, setKpi] = useState({ + todayTotal: 0, todayWaiting: 0, todayCompleted: 0, + purchaseCount: 0, outsourcingCount: 0, otherCount: 0, + todayQty: 0, todayQtyUnit: "EA", defectCount: 0, + }); + const [recentItems, setRecentItems] = useState([]); + const [loading, setLoading] = useState(true); + + const startKpiAuto = useCallback(() => { + if (kpiTimerRef.current) clearInterval(kpiTimerRef.current); + kpiTimerRef.current = setInterval(() => setKpiIdx((p) => (p + 1) % 3), 4000); + }, []); + + useEffect(() => { + startKpiAuto(); + return () => { if (kpiTimerRef.current) clearInterval(kpiTimerRef.current); }; + }, [startKpiAuto]); + + /* Fetch real data from API */ + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const today = new Date().toISOString().slice(0, 10); + + // 1) 금일 입고 (KPI용) + // 2) 최근 입고 전체 (최근 리스트용) + const [todayRes, allRes] = await Promise.all([ + apiClient.get("/receiving/list", { + params: { date_from: today, date_to: today }, + }), + apiClient.get("/receiving/list"), + ]); + + const todayRows: InboundRow[] = todayRes.data?.data ?? []; + const allRows: InboundRow[] = allRes.data?.data ?? []; + + // --- KPI 계산 --- + const todayTotal = todayRows.length; + const todayCompleted = todayRows.filter((r) => isCompleted(r.inbound_status)).length; + const todayWaiting = todayTotal - todayCompleted; + const purchaseCount = todayRows.filter((r) => isPurchase(r.inbound_type)).length; + const outsourcingCount = todayRows.filter((r) => isOutsourcing(r.inbound_type)).length; + const otherCount = todayTotal - purchaseCount - outsourcingCount; + const todayQty = todayRows.reduce((sum, r) => { + const q = typeof r.inbound_qty === "number" ? r.inbound_qty : Number(r.inbound_qty) || 0; + return sum + q; + }, 0); + const defectCount = todayRows.filter( + (r) => r.inspection_status === "불합격" || r.inspection_status === "불량" + ).length; + + setKpi({ + todayTotal, todayWaiting, todayCompleted, + purchaseCount, outsourcingCount, otherCount, + todayQty, todayQtyUnit: "EA", defectCount, + }); + + // --- 최근 입고 5건 --- + const sorted = [...allRows].sort((a, b) => { + const da = new Date(a.created_date || "").getTime() || 0; + const db = new Date(b.created_date || "").getTime() || 0; + return db - da; + }); + + // 동일 inbound_number로 그룹핑 (헤더-디테일 JOIN이므로 중복 가능) + const seen = new Set(); + const unique: InboundRow[] = []; + for (const row of sorted) { + const key = row.inbound_number; + if (!seen.has(key)) { + seen.add(key); + unique.push(row); + } + if (unique.length >= 5) break; + } + + const mapped: RecentInboundItem[] = unique.map((row, idx) => { + const dateObj = row.created_date ? new Date(row.created_date) : null; + const time = dateObj + ? `${String(dateObj.getHours()).padStart(2, "0")}:${String(dateObj.getMinutes()).padStart(2, "0")}` + : "--:--"; + const qtyNum = typeof row.inbound_qty === "number" ? row.inbound_qty : Number(row.inbound_qty) || 0; + const statusInfo = getStatusStyle(row.inbound_status); + + return { + id: String(row.id || idx), + time, + type: shortenType(row.inbound_type), + itemName: row.item_name || row.item_number || row.inbound_number || "-", + qty: `${qtyNum.toLocaleString()} ${row.unit || "EA"}`, + supplier: row.supplier_name || "-", + statusColor: statusInfo.color, + statusLabel: statusInfo.label, + }; + }); + + setRecentItems(mapped); + } catch { + // 실패 시 0/빈 배열 유지 + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const handleMenuClick = (item: InboundMenuItem) => { + if (item.href === "#") { + alert(`${item.title} 화면은 준비 중입니다.`); + } else { + router.push(item.href); + } + }; + + return ( +
+ {/* ===== Back + Title ===== */} +
+ +
+

입고

+

입고 유형을 선택하세요

+
+
+ + {/* ===== KPI Carousel ===== */} +
+
+ {/* Slide 1 — 금일 입고 현황 */} +
+
+
+ + + +
+
+
+ {/* Slide 2 — 유형별 건수 */} +
+
+
+ + + +
+
+
+ {/* Slide 3 — 수량/품질 */} +
+
+
+ + 0 ? ((1 - kpi.defectCount / kpi.todayTotal) * 100).toFixed(1) : "0")} label="합격률" color="text-green-600" unit="%" /> + +
+
+
+
+ {/* Dots */} +
+ {[0, 1, 2].map((idx) => ( +
+
+ + {/* ===== External Inbound ===== */} +
+
+
+

외부 입고

+
+
+ {EXTERNAL_ITEMS.map((item) => ( + + ))} +
+
+ + {/* ===== Internal Inbound ===== */} +
+
+
+

내부 입고

+
+
+ {INTERNAL_ITEMS.map((item) => ( + + ))} +
+
+ + {/* ===== Recent Inbound ===== */} +
+
+
+

최근 입고

+ 최근 5건 +
+
+ {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : recentItems.length === 0 ? ( +
+ 최근 입고 내역이 없습니다 +
+ ) : ( + recentItems.map((item) => ( +
+ + {item.time} + +
+
+ {item.itemName} + + {item.statusLabel} + +
+
+ {item.type} | {item.supplier} | {item.qty} +
+
+
+ )) + )} +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +function KpiCell({ value, label, color, unit }: { value: string; label: string; color: string; unit?: string }) { + return ( +
+
+ + {value} + + {unit && {unit}} +
+ {label} +
+ ); +} + +function MenuIcon({ item, onClick }: { item: InboundMenuItem; onClick: (item: InboundMenuItem) => void }) { + return ( +
onClick(item)} + > +
+ {item.icon} +
+ + {item.title} + +
+ ); +} diff --git a/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx b/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx new file mode 100644 index 00000000..bd4ba346 --- /dev/null +++ b/frontend/components/pop/hardcoded/inbound/InspectionModal.tsx @@ -0,0 +1,437 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { apiClient } from "@/lib/api/client"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export interface InspectionItem { + id: string; + inspection_item_name: string; + inspection_standard: string; + inspection_method: string; + pass_criteria: string; + is_required: string; + /** User-entered measured value */ + measured_value: string; + /** "pass" | "fail" | null */ + result: "pass" | "fail" | null; +} + +export interface InspectionResult { + items: InspectionItem[]; + goodQty: number; + badQty: number; + remark: string; + completed: boolean; +} + +interface InspectionModalProps { + open: boolean; + onClose: () => void; + onComplete: (result: InspectionResult) => void; + itemCode: string; + itemName: string; + totalQty: number; + initialResult?: InspectionResult | null; +} + +/* ------------------------------------------------------------------ */ +/* Dummy inspection items (fallback) */ +/* ------------------------------------------------------------------ */ + +const DUMMY_INSPECTION_ITEMS: InspectionItem[] = [ + { + id: "dummy-1", + inspection_item_name: "외관 검사", + inspection_standard: "스크래치, 변색, 찍힘 없음", + inspection_method: "육안 검사", + pass_criteria: "이상 없음", + is_required: "Y", + measured_value: "", + result: null, + }, + { + id: "dummy-2", + inspection_item_name: "치수 검사", + inspection_standard: "규격 +-0.5mm", + inspection_method: "캘리퍼스 측정", + pass_criteria: "허용 오차 이내", + is_required: "Y", + measured_value: "", + result: null, + }, + { + id: "dummy-3", + inspection_item_name: "수량 검사", + inspection_standard: "발주 수량과 일치", + inspection_method: "전수 검사", + pass_criteria: "수량 일치", + is_required: "Y", + measured_value: "", + result: null, + }, + { + id: "dummy-4", + inspection_item_name: "포장 상태", + inspection_standard: "포장 손상 없음", + inspection_method: "육안 검사", + pass_criteria: "이상 없음", + is_required: "", + measured_value: "", + result: null, + }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function InspectionModal({ + open, + onClose, + onComplete, + itemCode, + itemName, + totalQty, + initialResult, +}: InspectionModalProps) { + const [inspItems, setInspItems] = useState([]); + const [loading, setLoading] = useState(false); + const [goodQty, setGoodQty] = useState(0); + const [badQty, setBadQty] = useState(0); + const [remark, setRemark] = useState(""); + + /* Fetch inspection items from DB */ + const fetchInspectionItems = useCallback(async () => { + setLoading(true); + try { + const res = await apiClient.get("/pop/execute-action", { + params: { + taskType: "data-list", + targetTable: "item_inspection_info", + filters: JSON.stringify({ item_code: itemCode }), + pageSize: "50", + }, + }); + const data = res.data?.data; + if (Array.isArray(data) && data.length > 0) { + setInspItems( + data.map((r: Record) => ({ + id: String(r.id ?? ""), + inspection_item_name: String(r.inspection_item_name ?? ""), + inspection_standard: String(r.inspection_standard ?? ""), + inspection_method: String(r.inspection_method ?? ""), + pass_criteria: String(r.pass_criteria ?? ""), + is_required: String(r.is_required ?? ""), + measured_value: "", + result: null, + })) + ); + } else { + setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i }))); + } + } catch { + setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i }))); + } finally { + setLoading(false); + } + }, [itemCode]); + + /* Init on open */ + useEffect(() => { + if (!open) return; + if (initialResult) { + setInspItems(initialResult.items.map((i) => ({ ...i }))); + setGoodQty(initialResult.goodQty); + setBadQty(initialResult.badQty); + setRemark(initialResult.remark); + } else { + fetchInspectionItems(); + setGoodQty(totalQty); + setBadQty(0); + setRemark(""); + } + }, [open, initialResult, fetchInspectionItems, totalQty]); + + /* Update item */ + const updateItem = (id: string, field: "measured_value" | "result", value: string) => { + setInspItems((prev) => + prev.map((item) => + item.id === id + ? { ...item, [field]: field === "result" ? (item.result === value ? null : value) : value } + : item + ) + ); + }; + + /* Handle good/bad qty sync */ + const handleGoodQtyChange = (val: number) => { + const v = Math.max(0, Math.min(val, totalQty)); + setGoodQty(v); + setBadQty(totalQty - v); + }; + + const handleBadQtyChange = (val: number) => { + const v = Math.max(0, Math.min(val, totalQty)); + setBadQty(v); + setGoodQty(totalQty - v); + }; + + /* Complete */ + const handleComplete = () => { + onComplete({ + items: inspItems, + goodQty, + badQty, + remark, + completed: true, + }); + onClose(); + }; + + if (!open) return null; + + return ( +
+ {/* Full-screen slide panel */} +
+
+ {/* Header */} +
+

자주검사

+ +
+ + {/* Scrollable body */} +
+ {/* Item summary */} +
+ + {itemCode} + + + {itemName} + + + {totalQty.toLocaleString()} EA + +
+ + {/* Inspection items section */} +
+
+

검사 항목

+ + {inspItems.length}개 + +
+ + {loading ? ( +
+ + + + + 불러오는 중... +
+ ) : inspItems.length === 0 ? ( +
+ 등록된 검사 항목이 없습니다 +
+ ) : ( +
+ {inspItems.map((item) => ( +
+ {/* Item header */} +
+ + {item.inspection_item_name} + + {item.is_required === "Y" && ( + + 필수 + + )} +
+ + {/* Info grid */} +
+ {item.inspection_standard && ( + <> + 기준 + {item.inspection_standard} + + )} + {item.inspection_method && ( + <> + 방법 + {item.inspection_method} + + )} + {item.pass_criteria && ( + <> + 판정 + {item.pass_criteria} + + )} +
+ + {/* Input + result buttons */} +
+ updateItem(item.id, "measured_value", e.target.value)} + placeholder="측정값 입력" + className="flex-1 h-9 px-2.5 text-[13px] border border-gray-200 rounded-md outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100" + /> +
+ + +
+ {/* Camera placeholder */} + +
+
+ ))} +
+ )} +
+ + {/* Final judgment section */} +
+

종합 판정

+ + {/* Good / Bad qty */} +
+
+ +
+ handleGoodQtyChange(parseInt(e.target.value, 10) || 0)} + className="flex-1 min-w-0 h-10 px-2 text-center text-sm font-semibold border-2 border-green-400 rounded-lg bg-green-50 text-green-700 outline-none focus:ring-2 focus:ring-green-200" + /> + EA +
+
+
+ +
+ handleBadQtyChange(parseInt(e.target.value, 10) || 0)} + className="flex-1 min-w-0 h-10 px-2 text-center text-sm font-semibold border-2 border-red-400 rounded-lg bg-red-50 text-red-700 outline-none focus:ring-2 focus:ring-red-200" + /> + EA +
+
+
+ + {/* Total summary */} +
+ 전체 수량 + + {totalQty.toLocaleString()} EA + +
+
+ + {/* Remark */} +
+

비고

+