feat: POP 시연 준비 — 5개 화면 + 버그 수정 + 재고검증

This commit is contained in:
SeongHyun Kim
2026-04-09 14:28:57 +09:00
parent 0d62af8c8b
commit bfac350ed4
25 changed files with 2804 additions and 284 deletions
@@ -179,6 +179,23 @@ export async function create(req: AuthenticatedRequest, res: Response) {
const locCode = location_code || item.location_code || null;
const outQty = Number(item.outbound_qty) || 0;
if (itemCode && outQty > 0) {
// 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 ROLLBACK)
const stockCheck = await client.query(
`SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as cur
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
const currentStock = parseFloat(stockCheck.rows[0]?.cur || '0');
if (currentStock < outQty) {
throw new Error(
`재고 부족: ${item.item_name || itemCode} (창고 ${whCode || '미지정'}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`
);
}
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
@@ -1157,21 +1157,30 @@ export const saveResult = async (
}
if (shouldActivateNext) {
// 다음 seq 활성화 (completed도 재활성화 — 새 양품이 들어오면 추가 접수 가능)
const nextSeq = String(seqNum + 1);
const nextUpdate = await pool.query(
`UPDATE work_order_process
SET status = 'acceptable',
updated_date = NOW()
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NULL
RETURNING id, process_name, status`,
[wo_id, nextSeq, companyCode]
// 다음 seq 활성화 (seq_no 비순차 대응: seqNum+1이 아니라 "현재보다 큰 가장 작은 seq_no")
const nextSeqQuery = await pool.query(
`SELECT MIN(CAST(seq_no AS int)) as next_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL
AND CAST(seq_no AS int) > $3`,
[wo_id, companyCode, seqNum]
);
if (nextUpdate.rowCount > 0) {
logger.info("[pop/production] 다음 공정 상태 전환", {
nextProcess: nextUpdate.rows[0],
});
const actualNextSeq = nextSeqQuery.rows[0]?.next_seq;
if (actualNextSeq != null) {
const nextUpdate = await pool.query(
`UPDATE work_order_process
SET status = 'acceptable',
updated_date = NOW()
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NULL
RETURNING id, process_name, status`,
[wo_id, String(actualNextSeq), companyCode]
);
if (nextUpdate.rowCount > 0) {
logger.info("[pop/production] 다음 공정 상태 전환", {
nextProcess: nextUpdate.rows[0],
});
}
}
}
}
@@ -1672,17 +1681,37 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0;
prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
// 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단
// (라우팅 seq_no가 1, 2, 3이 아니라 10, 20, 30 같은 비순차여도 정상 동작)
const minSeqCheck = await pool.query(
`SELECT MIN(CAST(seq_no AS int)) as min_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`,
[wo_id, companyCode]
);
const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum;
const isFirstProcess = seqNum <= minSeq;
if (!isFirstProcess) {
// 이전 공정 찾기 (seq_no가 더 작은 가장 가까운 공정)
const prevProcessSeq = await pool.query(
`SELECT MAX(CAST(seq_no AS int)) as prev_seq
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[wo_id, prevSeq, companyCode]
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL
AND CAST(seq_no AS int) < $3`,
[wo_id, companyCode, seqNum]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq;
if (actualPrevSeq != null) {
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[wo_id, String(actualPrevSeq), companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
}
}
}
availableQty = Math.max(0, prevGoodQty - myInputQty);
@@ -1848,8 +1877,26 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0;
prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
// 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단
const minSeqCheck = await client.query(
`SELECT MIN(CAST(seq_no AS int)) as min_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`,
[row.wo_id, companyCode]
);
const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum;
const isFirstProcess = seqNum <= minSeq;
if (!isFirstProcess) {
// 이전 공정 = 이 공정보다 작은 seq_no 중 가장 큰 값
const prevProcessSeq = await client.query(
`SELECT MAX(CAST(seq_no AS int)) as prev_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL
AND CAST(seq_no AS int) < $3`,
[row.wo_id, companyCode, seqNum]
);
const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq;
const prevSeq = actualPrevSeq != null ? String(actualPrevSeq) : String(seqNum - 1);
const prevProcess = await client.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
@@ -689,7 +689,13 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price,
COALESCE(po.status, '') AS status,
COALESCE(pd.due_date, po.due_date) AS due_date,
'purchase_detail' AS source_table
'purchase_detail' AS source_table,
CASE WHEN EXISTS (
SELECT 1 FROM item_inspection_info iii
WHERE iii.company_code = pd.company_code
AND COALESCE(iii.is_active, 'Y') = 'Y'
AND iii.item_code = COALESCE(NULLIF(pd.item_code, ''), ii.item_number)
) THEN 'self' ELSE NULL END AS inspection_type
FROM purchase_detail pd
LEFT JOIN purchase_order_mng po
ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code
@@ -722,7 +728,13 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price,
po.status,
po.due_date,
'purchase_order_mng' AS source_table
'purchase_order_mng' AS source_table,
CASE WHEN EXISTS (
SELECT 1 FROM item_inspection_info iii
WHERE iii.company_code = po.company_code
AND COALESCE(iii.is_active, 'Y') = 'Y'
AND iii.item_code = po.item_code
) THEN 'self' ELSE NULL END AS inspection_type
FROM purchase_order_mng po
WHERE po.company_code = $1
AND NOT EXISTS (
+155 -37
View File
@@ -94,7 +94,59 @@ router.get("/", async (req: Request, res: Response) => {
}
});
// ---- 검사 결과 저장 (INSERT or UPDATE) ----
// ---- 검사번호 채번 (PC numberingRuleService 활용) ----
async function generateInspectionNumber(companyCode: string): Promise<string> {
// PC 채번 서비스 동적 import (순환 참조 방지)
const { numberingRuleService } = await import("../services/numberingRuleService");
// 1) inspection_result_mng / inspection_number 채번 규칙 조회
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
"inspection_result_mng",
"inspection_number"
);
if (rule && rule.ruleId) {
// 2) PC API와 동일한 allocateCode 호출 → 실제 시퀀스 +1
return await numberingRuleService.allocateCode(rule.ruleId, companyCode);
}
// fallback: 채번 규칙 없으면 단순 SELECT MAX
const { getPool } = await import("../database/db");
const pool = getPool();
const year = new Date().getFullYear();
const prefix = `QI-${year}-`;
const result = await pool.query(
`SELECT inspection_number FROM inspection_result_mng
WHERE company_code = $1 AND inspection_number LIKE $2
ORDER BY inspection_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let nextSeq = 1;
if (result.rows.length > 0) {
const lastNumber = result.rows[0].inspection_number;
const match = lastNumber.match(/(\d+)$/);
if (match) nextSeq = parseInt(match[1], 10) + 1;
}
return `${prefix}${String(nextSeq).padStart(4, "0")}`;
}
// ---- 검사번호 채번 전용 엔드포인트 (검사 모달에서 검사 완료 시) ----
// POST /api/pop/inspection-result/allocate-number
router.post("/allocate-number", async (req: Request, res: Response) => {
const companyCode = (req as any).user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 정보 없음" });
}
try {
const inspectionNumber = await generateInspectionNumber(companyCode);
return res.json({ success: true, data: { inspectionNumber } });
} catch (err: any) {
return res.status(500).json({ success: false, message: err.message });
}
});
// ---- 검사 결과 저장 (마스터 + 디테일 트랜잭션) ----
// POST /api/pop/inspection-result
router.post("/", async (req: Request, res: Response) => {
const pool = getPool();
@@ -106,6 +158,7 @@ router.post("/", async (req: Request, res: Response) => {
}
const {
inspectionNumber: providedNumber, // 프론트에서 미리 채번한 번호 (있으면 재사용)
referenceTable,
referenceId,
screenId,
@@ -115,7 +168,14 @@ router.post("/", async (req: Request, res: Response) => {
inspectionType,
items, // 검사 항목별 결과 배열
overallJudgment,
totalQty,
goodQty,
badQty,
defectDescription,
memo,
inspector,
supplierCode,
supplierName,
isCompleted,
} = req.body;
@@ -127,59 +187,117 @@ router.post("/", async (req: Request, res: Response) => {
try {
await client.query("BEGIN");
// 기존 결과 삭제 (동일 referenceId + referenceTable 기덮어쓰기)
// 1. 동일 referenceId + referenceTable 기존 마스터/디테일 삭제 (덮어쓰기)
if (referenceId && referenceTable) {
await client.query(
`DELETE FROM inspection_result
`DELETE FROM inspection_result WHERE master_id IN (
SELECT id FROM inspection_result_mng
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3
)`,
[companyCode, referenceId, referenceTable]
);
await client.query(
`DELETE FROM inspection_result_mng
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`,
[companyCode, referenceId, referenceTable]
);
}
const insertedIds: string[] = [];
// 2. 검사번호 (프론트에서 미리 받았으면 재사용, 없으면 새로 채번)
const inspectionNumber = providedNumber || await generateInspectionNumber(companyCode);
// 3. 마스터 INSERT
const completedFlag = isCompleted ? "Y" : "N";
const completedDate = isCompleted ? new Date() : null;
const masterResult = await client.query(
`INSERT INTO inspection_result_mng (
company_code, writer, inspection_number,
reference_table, reference_id, screen_id,
item_id, item_code, item_name,
inspection_type, total_qty, good_qty, bad_qty,
overall_judgment, defect_description, memo,
inspector, inspection_date,
supplier_code, supplier_name,
is_completed, completed_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, $20, $21
) RETURNING id, inspection_number`,
[
companyCode,
writer,
inspectionNumber,
referenceTable || null,
referenceId || null,
screenId || null,
itemId || null,
itemCode || null,
itemName || null,
inspectionType || null,
totalQty != null ? Number(totalQty) : null,
goodQty != null ? Number(goodQty) : null,
badQty != null ? Number(badQty) : null,
overallJudgment || null,
defectDescription || null,
memo || null,
inspector || writer,
supplierCode || null,
supplierName || null,
completedFlag,
completedDate,
]
);
const masterId = masterResult.rows[0].id;
// 4. 디테일 N건 INSERT
const insertedDetailIds: string[] = [];
for (const item of items) {
const completedFlag = isCompleted ? "Y" : "N";
const completedDate = isCompleted ? new Date() : null;
const insertSql = `
INSERT INTO inspection_result (
company_code, writer,
const detailResult = await client.query(
`INSERT INTO inspection_result (
company_code, writer, master_id,
reference_table, reference_id, screen_id,
inspection_info_id, item_id, item_code, item_name,
inspection_type, inspection_item_name, inspection_standard, pass_criteria, is_required,
measured_value, judgment, overall_judgment, memo,
is_completed, completed_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20
)
RETURNING id
`;
const result = await client.query(insertSql, [
companyCode,
writer,
referenceTable || null,
referenceId || null,
screenId || null,
item.inspectionInfoId || null,
itemId || item.itemId || null,
itemCode || item.itemCode || null,
itemName || item.itemName || null,
inspectionType || item.inspectionType || null,
item.inspectionItemName || null,
item.inspectionStandard || null,
item.passCriteria || null,
item.isRequired || "Y",
item.measuredValue || null,
item.judgment || null,
overallJudgment || null,
memo || null,
completedFlag,
completedDate,
]);
insertedIds.push(result.rows[0].id);
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21
) RETURNING id`,
[
companyCode,
writer,
masterId,
referenceTable || null,
referenceId || null,
screenId || null,
item.inspectionInfoId || null,
itemId || item.itemId || null,
itemCode || item.itemCode || null,
itemName || item.itemName || null,
inspectionType || item.inspectionType || null,
item.inspectionItemName || null,
item.inspectionStandard || null,
item.passCriteria || null,
item.isRequired || "Y",
item.measuredValue || null,
item.judgment || null,
overallJudgment || null,
memo || null,
completedFlag,
completedDate,
]
);
insertedDetailIds.push(detailResult.rows[0].id);
}
await client.query("COMMIT");
return res.json({ success: true, data: { ids: insertedIds } });
return res.json({
success: true,
data: {
masterId,
inspectionNumber,
detailIds: insertedDetailIds,
},
});
} catch (err: any) {
await client.query("ROLLBACK");
return res.status(500).json({ success: false, message: err.message });
@@ -0,0 +1,12 @@
"use client";
import { PopShell } from "@/components/pop/hardcoded";
import { InOutHistory } from "@/components/pop/hardcoded/inventory";
export default function InOutHistoryPage() {
return (
<PopShell showBanner={false} title="입출고관리">
<InOutHistory />
</PopShell>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { PopShell } from "@/components/pop/hardcoded";
import { InventoryHome } from "@/components/pop/hardcoded/inventory";
export default function InventoryPage() {
return (
<PopShell showBanner={false} title="재고">
<InventoryHome />
</PopShell>
);
}
@@ -0,0 +1,12 @@
"use client";
import { PopShell } from "@/components/pop/hardcoded";
import { InspectionList } from "@/components/pop/hardcoded/quality";
export default function InspectionListPage() {
return (
<PopShell showBanner={false} title="검사관리">
<InspectionList />
</PopShell>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { PopShell } from "@/components/pop/hardcoded";
import { QualityHome } from "@/components/pop/hardcoded/quality";
export default function QualityPage() {
return (
<PopShell showBanner={false} title="품질">
<QualityHome />
</PopShell>
);
}
@@ -60,7 +60,7 @@ const MENU_ITEMS: MenuIconItem[] = [
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
href: "/pop/screens/quality",
href: "/pop/quality",
},
{
id: "equipment",
@@ -84,7 +84,7 @@ const MENU_ITEMS: MenuIconItem[] = [
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
),
href: "/pop/screens/inventory",
href: "/pop/inventory",
},
// 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동
{
@@ -0,0 +1,82 @@
"use client";
import React from "react";
export interface ConfirmModalProps {
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "primary" | "danger" | "success";
onConfirm: () => void;
onCancel: () => void;
}
/**
* POP 공용 확인 모달 (native confirm() 대체)
* 모바일 친화 디자인, bottom-sheet 스타일
*/
export function ConfirmModal({
open,
title,
message,
confirmText = "확인",
cancelText = "취소",
variant = "primary",
onConfirm,
onCancel,
}: ConfirmModalProps) {
if (!open) return null;
const confirmBg =
variant === "danger"
? "bg-gradient-to-b from-red-500 to-red-600 hover:from-red-600 hover:to-red-700"
: variant === "success"
? "bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700"
: "bg-gradient-to-b from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700";
return (
<div className="fixed inset-0 z-[100]" onClick={onCancel}>
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" />
{/* Center modal */}
<div className="absolute inset-0 flex items-center justify-center p-6">
<div
className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Body */}
<div className="px-6 py-7 text-center">
{title && (
<h3 className="text-lg font-bold text-gray-900 mb-3">{title}</h3>
)}
<p className="text-base text-gray-700 whitespace-pre-line leading-relaxed">
{message}
</p>
</div>
{/* Buttons */}
<div className="flex border-t border-gray-100">
<button
type="button"
onClick={onCancel}
className="flex-1 py-4 text-base font-semibold text-gray-600 hover:bg-gray-50 active:bg-gray-100 transition-colors"
>
{cancelText}
</button>
<div className="w-px bg-gray-100" />
<button
type="button"
onClick={onConfirm}
className={`flex-1 py-4 text-base font-bold text-white transition-all active:scale-[0.98] ${confirmBg}`}
>
{confirmText}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -197,6 +197,55 @@ export function InboundCart({
const res = await apiClient.post("/receiving", payload);
if (res.data?.success) {
// 2-1. 검사 결과가 있는 항목 → inspection_result에 저장
const insertedDetails: any[] = res.data?.data?.details ?? res.data?.data?.items ?? [];
const inboundHeaderNo = res.data?.data?.header?.inbound_number || inboundNumber || "";
const inspectionPromises = items
.map((item, idx) => {
if (!item.inspectionResult?.completed) return null;
const matchedDetail = insertedDetails[idx] ?? {};
const referenceId = matchedDetail.id || matchedDetail.detail_id || `${inboundHeaderNo}-${idx + 1}`;
const goodQty = item.inspectionResult.goodQty || 0;
const badQty = item.inspectionResult.badQty || 0;
const totalQty = goodQty + badQty;
const overallJudgment = badQty === 0 ? "합격" : "불합격";
return apiClient.post("/pop/inspection-result", {
inspectionNumber: item.inspectionResult.inspectionNumber, // 카트에서 받은 검사번호 재사용
referenceTable: "inbound_mng",
referenceId,
screenId: "pop_inbound_inspection",
itemId: item.item_id || null,
itemCode: item.item_code,
itemName: item.item_name,
inspectionType: "입고검사",
overallJudgment,
totalQty,
goodQty,
badQty,
defectDescription: badQty > 0 ? `불량 ${badQty}` : "",
memo: item.inspectionResult.remark || "",
supplierCode: item.supplier_code || null,
supplierName: item.supplier_name || null,
isCompleted: true,
items: item.inspectionResult.items.map((insp: any) => ({
inspectionInfoId: insp.id || null,
inspectionItemName: insp.inspection_item_name,
inspectionStandard: insp.inspection_standard,
passCriteria: insp.pass_criteria,
isRequired: insp.is_required || "Y",
measuredValue: insp.measured_value || "",
judgment: insp.result || null,
})),
}).catch((err) => {
console.error("[inspection_result 저장 실패]", item.item_code, err?.message);
});
})
.filter(Boolean);
if (inspectionPromises.length > 0) {
await Promise.allSettled(inspectionPromises);
}
// 3. cart_items DB 정리 (백그라운드, 논블로킹)
// cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨)
const rowKeys = items.map((item) => item.source_id || item.id).filter(Boolean);
@@ -108,6 +108,37 @@ export function InboundCartPage() {
}
}, [items]);
/* Sync inspectionResults with cart.row.inspectionResult
* 페이지 새로고침/재진입 시 cart_items에 저장된 inspectionResult를 Map으로 복원.
* 주의: delete는 명시적 검사 취소(handleCancel)에서만 처리.
* (cart.saveToDb 후 row JSON이 stale할 수 있어 delete 로직은 race condition 유발) */
useEffect(() => {
setInspectionResults((prev) => {
const next = new Map(prev);
let changed = false;
cart.cartItems.forEach((c) => {
const stored = (c.row as Record<string, unknown>)?.inspectionResult;
if (stored && typeof stored === "object") {
// 유효한 검사 결과 → Map에 추가 (덮어쓰지 않음, 로컬 우선)
if (!next.has(c.rowKey)) {
next.set(c.rowKey, stored as InspectionResult);
changed = true;
}
}
// null/undefined여도 Map에서 자동 제거하지 않음 — 명시적 cancel만 처리
});
// 카트에서 사라진 rowKey만 정리 (실제 카트 삭제 시)
const cartKeys = new Set(cart.cartItems.map((c) => c.rowKey));
Array.from(next.keys()).forEach((k) => {
if (!cartKeys.has(k)) {
next.delete(k);
changed = true;
}
});
return changed ? next : prev;
});
}, [cart.cartItems]);
/* Warehouse */
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
@@ -248,29 +279,38 @@ export function InboundCartPage() {
const handleInspectionComplete = (result: InspectionResult) => {
if (!inspectionTarget) return;
const targetRowKey = inspectionTarget.rowKey;
setInspectionResults((prev) => {
const next = new Map(prev);
next.set(inspectionTarget.rowKey, result);
next.set(targetRowKey, result);
return next;
});
// cart_items.row_data에 검사 결과 저장 (페이지 새로고침해도 유지)
cart.updateItemRow(targetRowKey, { inspectionResult: result });
setInspectionTarget(null);
// 즉시 DB 저장 (자동저장 디바운스를 기다리지 않음)
setTimeout(() => {
cart.saveToDb().catch((err) => console.error("[검사 결과 저장 실패]", err));
}, 100);
};
/* Pass inspection (non-required only) */
const handlePassInspection = (rowKey: string) => {
const item = items.find((i) => i.rowKey === rowKey);
if (!item) return;
const result: InspectionResult = {
items: [],
goodQty: item.inbound_qty,
badQty: 0,
remark: "pass",
completed: true,
};
setInspectionResults((prev) => {
const next = new Map(prev);
next.set(rowKey, {
items: [],
goodQty: item.inbound_qty,
badQty: 0,
remark: "pass",
completed: true,
});
next.set(rowKey, result);
return next;
});
cart.updateItemRow(rowKey, { inspectionResult: result });
};
const getInspectionResult = (rowKey: string): InspectionResult | null => {
@@ -282,6 +322,8 @@ export function InboundCartPage() {
/* ------------------------------------------------------------------ */
const selectedItemsList = items.filter((i) => selectedItems.has(i.id));
// CEO 정책 (2026-04-09 시연 결정): 검사 필수 항목 미완료 시 확정 차단
// 검사 빠진 입고가 검사관리에서 추적 안 되므로, 입력 시점에 막음
const hasUnfinishedRequiredInspection = selectedItemsList.some(
(item) =>
item.inspection_required &&
@@ -300,10 +342,8 @@ export function InboundCartPage() {
return;
}
if (hasUnfinishedRequiredInspection) {
setResultMsg("오류: 필수 검사를 완료해주세요.");
return;
}
// 검사 미완료여도 확정 가능. 단지 inspection_result에 안 들어가거나 "대기" 상태로 기록.
// (CEO 정책: 입고 자체는 진행, 검사 결과만 누락/대기 상태로 표시)
setConfirming(true);
setResultMsg(null);
@@ -366,6 +406,66 @@ export function InboundCartPage() {
const res = await apiClient.post("/receiving", payload);
if (res.data?.success) {
// 검사 결과를 inspection_result_mng + inspection_result에 저장
const insertedDetails: Array<Record<string, unknown>> =
(res.data?.data?.details as Array<Record<string, unknown>>) ??
(res.data?.data?.items as Array<Record<string, unknown>>) ??
[];
const inboundHeaderNo: string =
(res.data?.data?.header as { inbound_number?: string } | undefined)?.inbound_number ||
finalNumber || "";
const inspectionPromises = selectedItemsList
.map((item, idx) => {
const inspResult = getInspectionResult(item.rowKey);
if (!inspResult?.completed) return null;
const matchedDetail = insertedDetails[idx] ?? {};
const referenceId =
(matchedDetail.id as string) ||
(matchedDetail.detail_id as string) ||
`${inboundHeaderNo}-${idx + 1}`;
const goodQty = inspResult.goodQty || 0;
const badQty = inspResult.badQty || 0;
const totalQty = goodQty + badQty;
const overallJudgment = badQty === 0 ? "합격" : "불합격";
return apiClient
.post("/pop/inspection-result", {
inspectionNumber: inspResult.inspectionNumber,
referenceTable: "inbound_mng",
referenceId,
screenId: "pop_inbound_inspection",
itemId: item.item_id || null,
itemCode: item.item_code,
itemName: item.item_name,
inspectionType: "입고검사",
overallJudgment,
totalQty,
goodQty,
badQty,
defectDescription: badQty > 0 ? `불량 ${badQty}` : "",
memo: inspResult.remark || "",
supplierCode: item.supplier_code || null,
supplierName: item.supplier_name || null,
isCompleted: true,
items: inspResult.items.map((insp) => ({
inspectionInfoId: insp.id || null,
inspectionItemName: insp.inspection_item_name,
inspectionStandard: insp.inspection_standard,
passCriteria: insp.pass_criteria,
isRequired: insp.is_required || "Y",
measuredValue: insp.measured_value || "",
judgment: insp.result || null,
})),
})
.catch((err: unknown) => {
const e = err as { message?: string };
console.error("[inspection_result 저장 실패]", item.item_code, e?.message);
});
})
.filter(Boolean);
if (inspectionPromises.length > 0) {
await Promise.all(inspectionPromises);
}
// Remove confirmed items from cart - direct DB delete for reliability
const confirmedItems = [...selectedItemsList];
const { dataApi } = await import("@/lib/api/data");
@@ -878,45 +978,17 @@ export function InboundCartPage() {
</div>
)}
{/* ===== Footer summary (no confirm button -- header only) ===== */}
{items.length > 0 && (
<div className="sticky bottom-0 bg-white border-t border-gray-200 rounded-t-2xl shadow-lg -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 py-4 mt-2">
{/* Result message */}
{resultMsg && (
<div
className={`mb-3 p-3 rounded-xl text-sm font-medium ${
resultMsg.startsWith("오류")
? "bg-red-50 text-red-700"
: "bg-green-50 text-green-700"
}`}
>
{resultMsg}
</div>
)}
{/* Required inspection warning */}
{hasUnfinishedRequiredInspection && (
<div className="mb-3 p-3 rounded-xl text-sm font-medium bg-amber-50 text-amber-700 border border-amber-200">
. .
</div>
)}
{/* Summary only (no big confirm button) */}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">
{" "}
<span className="font-bold text-gray-900">
{selectedItemsList.length}
</span>
/{items.length}
</span>
<span className="text-gray-500">
:{" "}
<span className="font-bold text-blue-600">
{totalQty.toLocaleString()}
</span>{" "}
EA
</span>
{/* ===== Result toast (only when message exists) ===== */}
{resultMsg && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
<div
className={`px-4 py-3 rounded-xl text-sm font-medium shadow-lg ${
resultMsg.startsWith("오류")
? "bg-red-50 text-red-700 border border-red-200"
: "bg-green-50 text-green-700 border border-green-200"
}`}
>
{resultMsg}
</div>
</div>
)}
@@ -1036,6 +1108,17 @@ export function InboundCartPage() {
setInspectionTarget(null);
}}
onComplete={handleInspectionComplete}
onCancel={() => {
// 검사 결과 무효화 (완료 → 대기 풀림)
const targetRowKey = inspectionTarget.rowKey;
setInspectionResults((prev) => {
const next = new Map(prev);
next.delete(targetRowKey);
return next;
});
cart.updateItemRow(targetRowKey, { inspectionResult: null });
setTimeout(() => cart.saveToDb().catch(() => {}), 100);
}}
itemCode={inspectionTarget.item_code}
itemName={inspectionTarget.item_name}
totalQty={inspectionTarget.inbound_qty}
@@ -26,12 +26,14 @@ export interface InspectionResult {
badQty: number;
remark: string;
completed: boolean;
inspectionNumber?: string; // 검사 완료 시 채번 받음 (재사용)
}
interface InspectionModalProps {
open: boolean;
onClose: () => void;
onComplete: (result: InspectionResult) => void;
onCancel?: () => void; // 취소 = 검사 무효화 (완료 → 대기)
itemCode: string;
itemName: string;
totalQty: number;
@@ -93,6 +95,7 @@ export function InspectionModal({
open,
onClose,
onComplete,
onCancel,
itemCode,
itemName,
totalQty,
@@ -102,19 +105,28 @@ export function InspectionModal({
const [loading, setLoading] = useState(false);
const [goodQty, setGoodQty] = useState(0);
const [badQty, setBadQty] = useState(0);
/* NumPad state */
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTitle, setNumpadTitle] = useState("");
const [numpadValue, setNumpadValue] = useState("");
const [numpadMax, setNumpadMax] = useState<number | undefined>(undefined);
const numpadCallbackRef = React.useRef<((val: string) => void) | null>(null);
const openNumpad = (title: string, currentValue: string | number, onConfirm: (v: string) => void, max?: number) => {
setNumpadTitle(title);
setNumpadValue(String(currentValue || ""));
setNumpadMax(max);
numpadCallbackRef.current = onConfirm;
setNumpadOpen(true);
};
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 res = await apiClient.get("/pop/inspection-result/info", {
params: { itemCode },
});
const data = res.data?.data;
if (Array.isArray(data) && data.length > 0) {
@@ -125,7 +137,7 @@ export function InspectionModal({
inspection_standard: String(r.inspection_standard ?? ""),
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? ""),
is_required: String(r.is_required ?? "Y"),
measured_value: "",
result: null,
}))
@@ -180,34 +192,66 @@ export function InspectionModal({
setGoodQty(totalQty - v);
};
/* 검사 완료 가능 여부: 필수 항목이 모두 pass */
const canComplete = inspItems
.filter((it) => it.is_required === "Y")
.every((it) => it.result === "pass");
/* Complete */
const handleComplete = () => {
onComplete({
items: inspItems,
goodQty,
badQty,
remark,
completed: true,
});
onClose();
const [allocating, setAllocating] = useState(false);
const handleComplete = async () => {
if (!canComplete) return;
setAllocating(true);
try {
// 1. 기존 inspectionNumber 있으면 재사용, 없으면 채번 호출
let inspectionNumber = initialResult?.inspectionNumber;
if (!inspectionNumber) {
try {
const res = await apiClient.post("/pop/inspection-result/allocate-number");
inspectionNumber = res.data?.data?.inspectionNumber;
} catch (err) {
console.error("[검사번호 채번 실패]", err);
}
}
// 2. 결과 전달 (채번 포함)
onComplete({
items: inspItems,
goodQty,
badQty,
remark,
completed: true,
inspectionNumber,
});
onClose();
} finally {
setAllocating(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex">
{/* Full-screen slide panel */}
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
<div className="fixed inset-0 z-50" onClick={onClose}>
{/* Overlay */}
<div className="absolute inset-0 bg-black/40" />
{/* Bottom sheet */}
<div
className="relative ml-auto w-full sm:w-[420px] h-full bg-white shadow-2xl z-10 flex flex-col"
style={{ animation: "slideInRight 0.3s ease" }}
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-2xl bg-white rounded-t-3xl shadow-2xl flex flex-col z-10"
style={{ maxHeight: "90vh" }}
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div className="pt-3 pb-2 flex justify-center rounded-t-3xl shrink-0">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 bg-gray-50 shrink-0">
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100 shrink-0">
<h3 className="text-lg font-bold text-gray-900"></h3>
<button
onClick={onClose}
className="w-9 h-9 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
className="w-9 h-9 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -298,13 +342,21 @@ export function InspectionModal({
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<input
type="text"
value={item.measured_value}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v)
)}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}
@@ -353,24 +405,26 @@ export function InspectionModal({
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-green-600"> </label>
<div className="flex items-center gap-1">
<input
type="number"
value={goodQty}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => openNumpad("양품 수량", goodQty, (v) => handleGoodQtyChange(parseInt(v, 10) || 0), totalQty)}
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 hover:bg-green-100 transition-all"
>
{goodQty.toLocaleString()}
</button>
<span className="text-[11px] text-gray-500 shrink-0">EA</span>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-red-600"> </label>
<div className="flex items-center gap-1">
<input
type="number"
value={badQty}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => openNumpad("불량 수량", badQty, (v) => handleBadQtyChange(parseInt(v, 10) || 0), totalQty)}
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 hover:bg-red-100 transition-all"
>
{badQty.toLocaleString()}
</button>
<span className="text-[11px] text-gray-500 shrink-0">EA</span>
</div>
</div>
@@ -401,10 +455,16 @@ export function InspectionModal({
{/* Footer */}
<div className="flex gap-2.5 px-5 py-4 border-t border-gray-200 bg-white shrink-0">
<button
onClick={onClose}
onClick={() => {
if (initialResult?.completed && onCancel) {
// 이미 완료된 검사 → 무효화 (완료 → 대기로 풀림)
onCancel();
}
onClose();
}}
className="flex-1 h-12 rounded-xl border border-gray-200 text-sm font-semibold text-gray-600 hover:bg-gray-50 active:scale-[0.98] transition-all"
>
{initialResult?.completed ? "검사 취소" : "취소"}
</button>
<button
className="flex-1 h-12 rounded-xl border border-gray-200 text-sm font-semibold text-gray-500 hover:bg-gray-50 active:scale-[0.98] transition-all"
@@ -414,24 +474,119 @@ export function InspectionModal({
</button>
<button
onClick={handleComplete}
className="flex-[2] h-12 rounded-xl text-sm font-bold text-white active:scale-[0.98] transition-all"
disabled={!canComplete || allocating}
className={`flex-[2] h-12 rounded-xl text-sm font-bold text-white transition-all ${
!canComplete || allocating
? "opacity-40 cursor-not-allowed"
: "active:scale-[0.98]"
}`}
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
boxShadow: "0 4px 12px rgba(59,130,246,.3)",
boxShadow: !canComplete || allocating ? "none" : "0 4px 12px rgba(59,130,246,.3)",
}}
title={!canComplete ? "필수 검사 항목을 모두 합격(✓)으로 체크해주세요" : ""}
>
{allocating ? "처리 중..." : "검사 완료"}
</button>
</div>
</div>
{/* Global keyframe (only mounts once) */}
<style dangerouslySetInnerHTML={{ __html: `
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
` }} />
{/* ===== NumPad ===== */}
{numpadOpen && (
<div className="absolute inset-0 z-20 flex items-end justify-center" onClick={() => setNumpadOpen(false)}>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-md bg-white rounded-t-3xl shadow-2xl flex flex-col z-30"
onClick={(e) => e.stopPropagation()}
>
<div className="pt-3 pb-2 flex justify-center">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
<h4 className="text-base font-bold text-gray-900">{numpadTitle}</h4>
<button onClick={() => setNumpadOpen(false)} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-5 pt-4 pb-2">
<div className="h-16 flex items-center justify-end px-4 bg-gray-50 rounded-xl border-2 border-gray-200 mb-3">
<span className="text-3xl font-bold text-gray-900" style={{ fontVariantNumeric: "tabular-nums" }}>
{numpadValue || "0"}
</span>
</div>
{numpadMax !== undefined && (
<p className="text-[11px] text-gray-400 text-right mb-2"> {numpadMax.toLocaleString()}</p>
)}
</div>
<div className="px-5 pb-5">
<div className="grid grid-cols-3 gap-2 mb-2">
{["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((k) => (
<button
key={k}
onClick={() => setNumpadValue((v) => (v === "0" || v === "" ? k : v + k))}
className="h-14 rounded-xl bg-gray-100 text-2xl font-bold text-gray-800 active:scale-95 active:bg-gray-200 transition-all"
>
{k}
</button>
))}
<button
onClick={() => setNumpadValue((v) => v.includes(".") ? v : (v || "0") + ".")}
className="h-14 rounded-xl bg-gray-100 text-2xl font-bold text-gray-800 active:scale-95 transition-all"
>
.
</button>
<button
onClick={() => setNumpadValue((v) => (v === "0" || v === "" ? "0" : v + "0"))}
className="h-14 rounded-xl bg-gray-100 text-2xl font-bold text-gray-800 active:scale-95 transition-all"
>
0
</button>
<button
onClick={() => setNumpadValue((v) => v.length <= 1 ? "" : v.slice(0, -1))}
className="h-14 rounded-xl bg-gray-200 text-base font-bold text-gray-600 active:scale-95 transition-all"
>
</button>
</div>
<div className="flex gap-2 mb-3">
<button
onClick={() => setNumpadValue("")}
className="flex-1 h-11 rounded-xl bg-gray-100 text-gray-600 text-sm font-bold active:scale-95"
>
</button>
{numpadMax !== undefined && (
<button
onClick={() => setNumpadValue(String(numpadMax))}
className="flex-1 h-11 rounded-xl bg-blue-50 text-blue-600 text-sm font-bold active:scale-95"
>
({numpadMax})
</button>
)}
</div>
<button
onClick={() => {
if (numpadCallbackRef.current) {
let val = numpadValue;
if (numpadMax !== undefined) {
const n = parseInt(val, 10) || 0;
val = String(Math.min(n, numpadMax));
}
numpadCallbackRef.current(val);
}
setNumpadOpen(false);
}}
className="w-full h-12 rounded-xl text-sm font-bold text-white active:scale-95 transition-all"
style={{ background: "linear-gradient(to bottom, #60a5fa, #2563eb)" }}
>
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -5,3 +5,5 @@ export { RecentActivity } from "./RecentActivity";
export { InboundTypeSelect, PurchaseInbound, SupplierModal, InboundCart } from "./inbound";
export { OutboundTypeSelect, SalesOutbound, CustomerModal, OutboundCartPage } from "./outbound";
export { WorkOrderList, ProcessWork, ProcessTimer, DefectTypeModal, AcceptProcessModal } from "./production";
export { InventoryHome, InOutHistory } from "./inventory";
export { QualityHome, InspectionList } from "./quality";
@@ -0,0 +1,250 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface DateRangePickerProps {
from: string; // YYYY-MM-DD
to: string; // YYYY-MM-DD
onChange: (from: string, to: string) => void;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function firstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay(); // 0=Sun
}
function fmt(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function fmtDisplay(dateStr: string): string {
if (!dateStr) return "";
const [y, m, d] = dateStr.split("-");
return `${y}.${m}.${d}`;
}
function isSame(a: string, b: string): boolean {
return a === b;
}
function isBetween(date: string, from: string, to: string): boolean {
return date >= from && date <= to;
}
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"];
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const [selecting, setSelecting] = useState<"from" | "to" | null>(null);
const [tempFrom, setTempFrom] = useState(from);
const [tempTo, setTempTo] = useState(to);
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const handleOpen = () => {
setTempFrom(from);
setTempTo(to);
setSelecting("from");
const d = from ? new Date(from) : new Date();
setViewYear(d.getFullYear());
setViewMonth(d.getMonth());
setOpen(true);
};
const handleDayClick = (dateStr: string) => {
if (selecting === "from") {
setTempFrom(dateStr);
setTempTo(dateStr); // 같은 날짜 = 당일
setSelecting("to");
} else {
// to 선택
if (dateStr < tempFrom) {
// 시작일보다 이전 선택 → 시작일로 교체
setTempFrom(dateStr);
setTempTo(dateStr);
setSelecting("to");
} else {
setTempTo(dateStr);
onChange(tempFrom, dateStr);
setOpen(false);
setSelecting(null);
}
}
};
const prevMonth = () => {
if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11); }
else setViewMonth(viewMonth - 1);
};
const nextMonth = () => {
if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0); }
else setViewMonth(viewMonth + 1);
};
// Quick select presets
const today = fmt(new Date());
const presets = [
{ label: "오늘", from: today, to: today },
{ label: "이번주", from: fmt(new Date(new Date().setDate(new Date().getDate() - new Date().getDay()))), to: today },
{ label: "이번달", from: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}-01`, to: today },
];
// Display text
const displayText = from && to
? isSame(from, to)
? fmtDisplay(from)
: `${fmtDisplay(from)} ~ ${fmtDisplay(to)}`
: "기간 선택";
// Build calendar grid
const totalDays = daysInMonth(viewYear, viewMonth);
const startDay = firstDayOfMonth(viewYear, viewMonth);
const cells: (string | null)[] = [];
for (let i = 0; i < startDay; i++) cells.push(null);
for (let d = 1; d <= totalDays; d++) {
const dateStr = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
cells.push(dateStr);
}
return (
<div ref={containerRef} className="relative">
{/* Trigger Button */}
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<button
onClick={handleOpen}
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-left focus:outline-none focus:border-cyan-400 focus:ring-2 focus:ring-cyan-100 bg-white flex items-center justify-between gap-2"
>
<span className={from ? "text-gray-900 font-medium" : "text-gray-400"}>{displayText}</span>
<svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
</button>
</div>
{/* Calendar Popup */}
{open && (
<div className="absolute left-0 top-full mt-2 z-50 bg-white rounded-2xl shadow-xl border border-gray-200 p-4 w-[320px]">
{/* Header hint */}
<p className="text-[10px] text-center text-gray-400 mb-2">
{selecting === "from" ? "시작일을 선택하세요" : "종료일을 선택하세요 (같은 날 = 당일)"}
</p>
{/* Quick Presets */}
<div className="flex gap-1.5 mb-3">
{presets.map((p) => (
<button
key={p.label}
onClick={() => { onChange(p.from, p.to); setOpen(false); }}
className="flex-1 py-1.5 rounded-lg text-[11px] font-semibold text-cyan-700 bg-cyan-50 hover:bg-cyan-100 active:scale-95 transition-all"
>
{p.label}
</button>
))}
</div>
{/* Month Navigation */}
<div className="flex items-center justify-between mb-3">
<button onClick={prevMonth} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-100 active:scale-95">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /></svg>
</button>
<span className="text-sm font-bold text-gray-900">{viewYear} {MONTH_NAMES[viewMonth]}</span>
<button onClick={nextMonth} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-100 active:scale-95">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
</button>
</div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 gap-0 mb-1">
{WEEKDAYS.map((d, i) => (
<div key={d} className={`text-center text-[10px] font-semibold py-1 ${i === 0 ? "text-red-400" : i === 6 ? "text-blue-400" : "text-gray-400"}`}>
{d}
</div>
))}
</div>
{/* Day Grid */}
<div className="grid grid-cols-7 gap-0">
{cells.map((dateStr, idx) => {
if (!dateStr) return <div key={`empty-${idx}`} className="h-10" />;
const day = parseInt(dateStr.split("-")[2], 10);
const dayOfWeek = new Date(dateStr).getDay();
const isStart = isSame(dateStr, tempFrom);
const isEnd = isSame(dateStr, tempTo);
const isInRange = tempFrom && tempTo && isBetween(dateStr, tempFrom, tempTo);
const isToday = isSame(dateStr, today);
let bgClass = "hover:bg-gray-100";
let textClass = dayOfWeek === 0 ? "text-red-500" : dayOfWeek === 6 ? "text-blue-500" : "text-gray-700";
if (isStart || isEnd) {
bgClass = "bg-cyan-600 text-white";
textClass = "text-white";
} else if (isInRange) {
bgClass = "bg-cyan-50";
textClass = "text-cyan-700";
}
return (
<button
key={dateStr}
onClick={() => handleDayClick(dateStr)}
className={`h-10 flex items-center justify-center text-sm font-medium rounded-lg transition-all active:scale-90 ${bgClass} ${textClass}`}
>
<span className="relative">
{day}
{isToday && !isStart && !isEnd && (
<span className="absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-cyan-500" />
)}
</span>
</button>
);
})}
</div>
{/* Selected Range Display */}
{tempFrom && (
<div className="mt-3 pt-3 border-t border-gray-100 text-center">
<span className="text-xs text-gray-500">
{isSame(tempFrom, tempTo) ? fmtDisplay(tempFrom) : `${fmtDisplay(tempFrom)} ~ ${fmtDisplay(tempTo)}`}
</span>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,551 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "./DateRangePicker";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface HistoryItem {
id: string;
direction: "입고" | "출고";
docNumber: string;
type: string;
itemName: string;
itemCode: string;
spec: string;
qty: number;
unit: string;
unitPrice: number;
totalAmount: number;
warehouse: string;
warehouseCode: string;
locationCode: string;
lotNumber: string;
partnerName: string;
referenceNumber: string;
writer: string;
memo: string;
status: string;
statusColor: string;
statusLabel: string;
time: string;
date: string;
fullDate: string;
}
interface KpiData {
inbound: number;
outbound: number;
transfer: number;
total: number;
}
type TabKey = "all" | "inbound" | "outbound" | "transfer";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getStatusStyle(status: string | null): { color: string; label: string } {
switch (status) {
case "완료":
case "입고완료":
case "출고완료":
return { color: "text-green-600 bg-green-50", label: "완료" };
case "대기":
return { color: "text-amber-600 bg-amber-50", label: "대기" };
case "진행중":
case "부분입고":
case "부분출고":
return { color: "text-blue-600 bg-blue-50", label: "진행중" };
default:
return { color: "text-gray-600 bg-gray-50", label: status || "대기" };
}
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InOutHistory() {
const router = useRouter();
/* Filter state */
const [dateFrom, setDateFrom] = useState(() => new Date().toISOString().slice(0, 10));
const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10));
const [keyword, setKeyword] = useState("");
const [warehouse, setWarehouse] = useState("전체");
const [warehouses, setWarehouses] = useState<{ code: string; name: string }[]>([]);
/* Data state */
const [items, setItems] = useState<HistoryItem[]>([]);
const [kpi, setKpi] = useState<KpiData>({ inbound: 0, outbound: 0, transfer: 0, total: 0 });
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [selectedItem, setSelectedItem] = useState<HistoryItem | null>(null);
/* Fetch warehouses */
useEffect(() => {
apiClient.get("/outbound/warehouses").then((res) => {
const data = res.data?.data ?? [];
setWarehouses(data.map((w: any) => ({ code: w.warehouse_code || "", name: w.warehouse_name || "" })));
}).catch(() => {});
}, []);
/* Fetch data */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (dateFrom) params.date_from = dateFrom;
if (dateTo) params.date_to = dateTo;
const [inRes, outRes] = await Promise.all([
apiClient.get("/receiving/list", { params }),
apiClient.get("/outbound/list", { params }),
]);
const inRows: any[] = inRes.data?.data ?? [];
const outRows: any[] = outRes.data?.data ?? [];
const combined: HistoryItem[] = [
...inRows.map((r: any, idx: number) => {
const st = getStatusStyle(r.inbound_status);
return {
id: `in-${r.detail_id || r.id}-${idx}`,
direction: "입고" as const,
docNumber: r.inbound_number || "-",
type: r.inbound_type || "입고",
itemName: r.item_name || "-",
itemCode: r.item_number || "",
spec: r.specification || r.spec || "",
qty: Number(r.inbound_qty || 0),
unit: r.unit || "EA",
unitPrice: Number(r.unit_price || 0),
totalAmount: Number(r.total_amount || 0),
warehouse: r.warehouse_name || "-",
warehouseCode: r.warehouse_code || "",
locationCode: r.location_code || "",
lotNumber: r.lot_number || "",
partnerName: r.supplier_name || "-",
referenceNumber: r.reference_number || "",
writer: r.writer || r.created_by || "",
memo: r.memo || "",
status: r.inbound_status || "",
statusColor: st.color,
statusLabel: st.label,
time: r.created_date ? new Date(r.created_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--",
date: r.inbound_date || r.created_date?.slice(0, 10) || "",
fullDate: r.created_date ? new Date(r.created_date).toLocaleString("ko-KR") : "-",
};
}),
...outRows.map((r: any, idx: number) => {
const st = getStatusStyle(r.outbound_status);
return {
id: `out-${r.id}-${idx}`,
direction: "출고" as const,
docNumber: r.outbound_number || "-",
type: r.outbound_type || "출고",
itemName: r.item_name || "-",
itemCode: r.item_code || "",
spec: r.specification || r.spec || "",
qty: Number(r.outbound_qty || 0),
unit: r.unit || "EA",
unitPrice: Number(r.unit_price || 0),
totalAmount: Number(r.total_amount || 0),
warehouse: r.warehouse_name || "-",
warehouseCode: r.warehouse_code || "",
locationCode: r.location_code || "",
lotNumber: r.lot_number || "",
partnerName: r.customer_name || "-",
referenceNumber: r.reference_number || "",
writer: r.writer || r.created_by || "",
memo: r.memo || "",
status: r.outbound_status || "",
statusColor: st.color,
statusLabel: st.label,
time: r.created_date ? new Date(r.created_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--",
date: r.outbound_date || r.created_date?.slice(0, 10) || "",
fullDate: r.created_date ? new Date(r.created_date).toLocaleString("ko-KR") : "-",
};
}),
].sort((a, b) => b.time.localeCompare(a.time));
setItems(combined);
setKpi({
inbound: inRows.length,
outbound: outRows.length,
transfer: 0,
total: inRows.length + outRows.length,
});
} catch {
setItems([]);
setKpi({ inbound: 0, outbound: 0, transfer: 0, total: 0 });
} finally {
setLoading(false);
}
}, [dateFrom, dateTo]);
useEffect(() => {
fetchData();
}, [fetchData]);
/* Filter by tab + keyword + warehouse */
const filtered = items.filter((item) => {
if (activeTab === "inbound" && item.direction !== "입고") return false;
if (activeTab === "outbound" && item.direction !== "출고") return false;
if (activeTab === "transfer") return false; // 준비 중
if (keyword) {
const kw = keyword.toLowerCase();
if (!item.itemName.toLowerCase().includes(kw) && !item.itemCode.toLowerCase().includes(kw)) return false;
}
if (warehouse !== "전체" && item.warehouse !== warehouse) return false;
return true;
});
const TABS: { key: TabKey; label: string; count: number; disabled?: boolean }[] = [
{ key: "all", label: "전체", count: kpi.total },
{ key: "inbound", label: "입고", count: kpi.inbound },
{ key: "outbound", label: "출고", count: kpi.outbound },
{ key: "transfer", label: "이동", count: kpi.transfer, disabled: true },
];
return (
<div className="flex flex-col gap-4">
{/* Back + Title */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/inventory")}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5">· </p>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="flex items-end gap-2">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2">
<DateRangePicker
from={dateFrom}
to={dateTo}
onChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
/>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="품목명 / 코드 검색"
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<select
value={warehouse}
onChange={(e) => setWarehouse(e.target.value)}
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400 bg-white"
>
<option value="전체"></option>
{warehouses.map((w) => (
<option key={w.code} value={w.name}>{w.name}</option>
))}
</select>
</div>
</div>
<div className="flex gap-1.5 shrink-0 pb-[1px]">
<button
onClick={fetchData}
className="h-[42px] px-4 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all"
style={{ background: "linear-gradient(135deg,#06b6d4,#0e7490)" }}
>
</button>
<button
onClick={() => { setDateFrom(new Date().toISOString().slice(0, 10)); setDateTo(new Date().toISOString().slice(0, 10)); setKeyword(""); setWarehouse("전체"); }}
className="h-[42px] w-[42px] rounded-lg text-sm font-semibold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
>
</button>
</div>
</div>
</div>
{/* KPI */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="grid grid-cols-4 gap-0">
<KpiCell icon="📥" value={loading ? "-" : kpi.inbound.toLocaleString()} label="입고" color="text-blue-600" />
<KpiCell icon="📤" value={loading ? "-" : kpi.outbound.toLocaleString()} label="출고" color="text-green-600" />
<KpiCell icon="🔄" value={loading ? "-" : kpi.transfer.toLocaleString()} label="이동" color="text-gray-400" />
<KpiCell icon="📊" value={loading ? "-" : kpi.total.toLocaleString()} label="전체" color="text-gray-900" />
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
disabled={tab.disabled}
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
tab.disabled
? "text-gray-300 bg-gray-50 cursor-not-allowed"
: activeTab === tab.key
? "text-white shadow-sm"
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
}`}
style={
!tab.disabled && activeTab === tab.key
? { background: "linear-gradient(135deg,#06b6d4,#0e7490)" }
: undefined
}
>
{tab.label} {tab.count}
</button>
))}
</div>
{/* List */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-xs text-gray-400"> {filtered.length}</span>
</div>
{loading ? (
<div className="flex flex-col gap-3 py-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-2xl border border-gray-100 p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gray-100" />
<div className="flex-1 flex flex-col gap-2">
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-3 bg-gray-50 rounded w-1/2" />
</div>
<div className="h-5 w-12 bg-gray-100 rounded-full" />
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
) : (
filtered.map((item) => (
<div key={item.id} onClick={() => setSelectedItem(item)} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]">
<div className="flex items-center gap-3">
{/* Direction icon */}
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0 ${
item.direction === "입고" ? "" : ""
}`}
style={{
background: item.direction === "입고"
? "linear-gradient(135deg,#3b82f6,#1d4ed8)"
: "linear-gradient(135deg,#22c55e,#15803d)",
}}
>
{item.direction === "입고" ? "📥" : "📤"}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-900 truncate">
{item.itemName}
{item.itemCode ? ` (${item.itemCode})` : ""}
</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}>
{item.statusLabel}
</span>
</div>
<div className="text-xs text-gray-400 mt-1">
{item.type} · {item.warehouse}
</div>
</div>
{/* Qty + Time */}
<div className="text-right shrink-0">
<p className="text-base font-bold text-gray-900" style={{ fontVariantNumeric: "tabular-nums" }}>
{item.qty.toLocaleString()} <span className="text-xs font-normal text-gray-400">{item.unit}</span>
</p>
<p className="text-[10px] text-gray-400 mt-0.5">{item.time}</p>
</div>
</div>
</div>
))
)}
</div>
{/* Detail Bottom Sheet */}
{selectedItem && (
<div className="fixed inset-0 z-50 flex items-end justify-center" onClick={() => setSelectedItem(null)}>
{/* Overlay */}
<div className="absolute inset-0 bg-black/40 transition-opacity" />
{/* Sheet */}
<div
className="relative w-full max-w-lg bg-white rounded-t-3xl shadow-2xl max-h-[85vh] overflow-y-auto animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div className="sticky top-0 bg-white pt-3 pb-2 flex justify-center rounded-t-3xl z-10">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 pb-4 border-b border-gray-100">
<h3 className="text-lg font-bold text-gray-900">
{selectedItem.direction === "입고" ? "입고" : "출고"} {selectedItem.docNumber}
</h3>
<button
onClick={() => setSelectedItem(null)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-5">
{/* Row 1: 전표번호 + 구분 */}
<div className="grid grid-cols-2 gap-4">
<DetailField label="전표번호" value={selectedItem.docNumber} />
<DetailField label="구분" value={selectedItem.type} />
</div>
{/* Row 2: 일시 + 상태 */}
<div className="grid grid-cols-2 gap-4">
<DetailField label="일시" value={selectedItem.fullDate} />
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1"></p>
<span className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${selectedItem.statusColor}`}>
{selectedItem.statusLabel}
</span>
</div>
</div>
<div className="border-t border-gray-100" />
{/* Row 3: 품목 */}
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1"></p>
<p className="text-base font-bold text-gray-900">
{selectedItem.itemName}
{selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""}
{selectedItem.spec ? <span className="text-sm font-normal text-gray-400 ml-2">{selectedItem.spec}</span> : null}
</p>
</div>
{/* Row 4: 수량 + LOT */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1"></p>
<p className="text-xl font-bold text-cyan-600" style={{ fontVariantNumeric: "tabular-nums" }}>
{selectedItem.qty.toLocaleString()} <span className="text-sm font-normal text-gray-400">{selectedItem.unit}</span>
</p>
</div>
<DetailField label="LOT번호" value={selectedItem.lotNumber || "-"} />
</div>
<div className="border-t border-gray-100" />
{/* Row 5: 창고/위치 + 거래처 */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1"> / </p>
<p className="text-sm font-bold text-gray-900">{selectedItem.warehouse}</p>
{selectedItem.locationCode && <p className="text-xs text-gray-400">{selectedItem.locationCode}</p>}
</div>
<DetailField label="거래처" value={selectedItem.partnerName} />
</div>
{/* Row 6: 작업자 + 비고 */}
<div className="grid grid-cols-2 gap-4">
<DetailField label="작업자" value={selectedItem.writer || "-"} />
<DetailField label="비고" value={selectedItem.memo || "-"} />
</div>
{/* Row 7: 참조번호 + 금액 (있을 때만) */}
{(selectedItem.referenceNumber || selectedItem.totalAmount > 0) && (
<div className="grid grid-cols-2 gap-4">
{selectedItem.referenceNumber ? <DetailField label="참조번호" value={selectedItem.referenceNumber} /> : <div />}
{selectedItem.totalAmount > 0 ? (
<DetailField label="금액" value={`${selectedItem.totalAmount.toLocaleString()}`} />
) : <div />}
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-gray-100">
<button
onClick={() => setSelectedItem(null)}
className="w-full py-3 rounded-xl text-sm font-bold text-gray-600 bg-gray-100 active:scale-95 transition-all"
>
</button>
</div>
</div>
</div>
)}
<style jsx>{`
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
`}</style>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">{label}</p>
<p className="text-sm font-semibold text-gray-900">{value}</p>
</div>
);
}
function KpiCell({ icon, value, label, color }: { icon: string; value: string; label: string; color: string }) {
return (
<div className="flex flex-col items-center py-2">
<span className="text-lg mb-0.5">{icon}</span>
<span
className={`text-xl sm:text-2xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{value}
</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">{label}</span>
</div>
);
}
@@ -0,0 +1,285 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface RecentItem {
id: string;
time: string;
direction: "입고" | "출고";
type: string;
itemName: string;
qty: string;
partnerName: string;
statusColor: string;
statusLabel: string;
}
interface KpiData {
todayInbound: number;
todayOutbound: number;
todayTotal: number;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getStatusStyle(status: string | null): { color: string; label: string } {
switch (status) {
case "완료":
case "입고완료":
case "출고완료":
return { color: "text-green-600 bg-green-50", label: "완료" };
case "대기":
return { color: "text-amber-600 bg-amber-50", label: "대기" };
case "진행중":
return { color: "text-blue-600 bg-blue-50", label: "진행중" };
default:
return { color: "text-gray-600 bg-gray-50", label: status || "대기" };
}
}
/* ------------------------------------------------------------------ */
/* Menu Items */
/* ------------------------------------------------------------------ */
const MENU_ITEMS = [
{
id: "history",
title: "입출고관리",
gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
shadowColor: "rgba(59,130,246,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 7.5h-.75A2.25 2.25 0 004.5 9.75v7.5a2.25 2.25 0 002.25 2.25h7.5a2.25 2.25 0 002.25-2.25v-7.5a2.25 2.25 0 00-2.25-2.25h-.75m-6 3.75l3 3m0 0l3-3m-3 3V1.5m6 9h.75a2.25 2.25 0 012.25 2.25v7.5a2.25 2.25 0 01-2.25 2.25h-7.5a2.25 2.25 0 01-2.25-2.25v-7.5a2.25 2.25 0 012.25-2.25H12" />
</svg>
),
href: "/pop/inventory/history",
},
{
id: "adjust",
title: "재고조정",
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
shadowColor: "rgba(245,158,11,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
</svg>
),
href: "#",
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InventoryHome() {
const router = useRouter();
const [kpi, setKpi] = useState<KpiData>({ todayInbound: 0, todayOutbound: 0, todayTotal: 0 });
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const today = new Date().toISOString().slice(0, 10);
const [inRes, outRes] = 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 inRows: any[] = inRes.data?.data ?? [];
const outRows: any[] = outRes.data?.data ?? [];
setKpi({
todayInbound: inRows.length,
todayOutbound: outRows.length,
todayTotal: inRows.length + outRows.length,
});
const combined: RecentItem[] = [
...inRows.map((r: any, idx: number) => {
const st = getStatusStyle(r.inbound_status);
return {
id: `in-${r.detail_id || r.id}-${idx}`,
time: r.created_date ? new Date(r.created_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--",
direction: "입고" as const,
type: r.inbound_type || "입고",
itemName: r.item_name || r.item_number || "-",
qty: `${Number(r.inbound_qty || 0).toLocaleString()} ${r.unit || "EA"}`,
partnerName: r.supplier_name || "-",
statusColor: st.color,
statusLabel: st.label,
};
}),
...outRows.map((r: any, idx: number) => {
const st = getStatusStyle(r.outbound_status);
return {
id: `out-${r.id}-${idx}`,
time: r.created_date ? new Date(r.created_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--",
direction: "출고" as const,
type: r.outbound_type || "출고",
itemName: r.item_name || r.item_code || "-",
qty: `${Number(r.outbound_qty || 0).toLocaleString()} ${r.unit || "EA"}`,
partnerName: r.customer_name || "-",
statusColor: st.color,
statusLabel: st.label,
};
}),
]
.sort((a, b) => b.time.localeCompare(a.time))
.slice(0, 5);
setRecentItems(combined);
} catch {
// keep empty
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleMenuClick = (item: (typeof MENU_ITEMS)[number]) => {
if (item.href === "#") {
alert(`${item.title} 화면은 준비 중입니다.`);
} else {
router.push(item.href);
}
};
return (
<div className="flex flex-col gap-5">
{/* Back + Title */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/home")}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* KPI */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<div className="grid grid-cols-3 gap-0">
<KpiCell value={loading ? "-" : kpi.todayInbound.toLocaleString()} label="금일 입고" color="text-blue-600" />
<KpiCell value={loading ? "-" : kpi.todayOutbound.toLocaleString()} label="금일 출고" color="text-green-600" />
<KpiCell value={loading ? "-" : kpi.todayTotal.toLocaleString()} label="전체" color="text-gray-900" />
</div>
</div>
{/* Menu Icons */}
<section>
<div className="flex items-center gap-2 mb-3">
<div className="w-1 h-5 rounded-full bg-cyan-500" />
<h2 className="text-base sm:text-lg font-bold text-gray-900"> </h2>
</div>
<div className="flex flex-wrap justify-start gap-x-5 gap-y-4 sm:gap-x-6 sm:gap-y-5">
{MENU_ITEMS.map((item) => (
<div
key={item.id}
className="flex flex-col items-center gap-2 w-16 sm:w-[72px] cursor-pointer group"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => handleMenuClick(item)}
>
<div
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-105 group-active:scale-[0.93]"
style={{ background: item.gradient, boxShadow: `0 4px 12px ${item.shadowColor}` }}
>
{item.icon}
</div>
<span className="text-[11px] sm:text-xs font-semibold text-gray-700 text-center leading-tight">
{item.title}
</span>
</div>
))}
</div>
</section>
{/* Recent Activity */}
<section>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-100">
<h3 className="text-base sm:text-lg font-bold text-gray-900"> </h3>
<span className="text-xs text-gray-400"> 5</span>
</div>
<div className="flex flex-col gap-2">
{loading ? (
<div className="flex flex-col gap-3 py-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3">
<div className="w-[44px] h-4 bg-gray-100 rounded animate-pulse" />
<div className="flex-1 flex flex-col gap-1.5">
<div className="h-4 bg-gray-100 rounded w-3/4 animate-pulse" />
<div className="h-3 bg-gray-50 rounded w-1/2 animate-pulse" />
</div>
</div>
))}
</div>
) : recentItems.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400"> </div>
) : (
recentItems.map((item) => (
<div key={item.id} className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors">
<span className="text-xs font-semibold text-gray-400 min-w-[44px] text-right" style={{ fontVariantNumeric: "tabular-nums" }}>
{item.time}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.direction === "입고" ? "text-blue-600 bg-blue-50" : "text-green-600 bg-green-50"}`}>
{item.direction}
</span>
<span className="text-sm font-semibold text-gray-900 truncate">{item.itemName}</span>
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}>
{item.statusLabel}
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5 truncate">
{item.type} | {item.partnerName} | {item.qty}
</div>
</div>
</div>
))
)}
</div>
</div>
</section>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function KpiCell({ value, label, color }: { value: string; label: string; color: string }) {
return (
<div className="flex flex-col items-center py-2">
<span
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
>
{value}
</span>
<span className="text-[11px] font-medium text-gray-400 mt-1">{label}</span>
</div>
);
}
@@ -0,0 +1,2 @@
export { InventoryHome } from "./InventoryHome";
export { InOutHistory } from "./InOutHistory";
@@ -381,8 +381,10 @@ export function OutboundCartPage() {
);
}
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : "출고 등록에 실패했습니다.";
// axios 에러 우선: 백엔드 message가 있으면 그것을 표시 (재고 부족 등)
const e = err as { response?: { data?: { message?: string } }; message?: string };
const backendMsg = e?.response?.data?.message;
const msg = backendMsg || e?.message || "출고 등록에 실패했습니다.";
setResultMsg(`오류: ${msg}`);
} finally {
setConfirming(false);
@@ -7,6 +7,7 @@ import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { dataApi } from "@/lib/api/data";
import { ProcessTimer, type TimerStatus } from "./ProcessTimer";
import { DefectTypeModal, type DefectEntry, type DefectType } from "./DefectTypeModal";
import { ConfirmModal } from "../common/ConfirmModal";
/* ================================================================== */
/* Types */
@@ -337,6 +338,31 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
/* ---- Modals ---- */
const [prodQtyModal, setProdQtyModal] = useState(false);
const [defectModal, setDefectModal] = useState(false);
const [confirmModalState, setConfirmModalState] = useState<{
open: boolean;
message: string;
title?: string;
confirmText?: string;
variant?: "primary" | "danger" | "success";
onConfirm: () => void;
}>({ open: false, message: "", onConfirm: () => {} });
const askConfirm = (
message: string,
onConfirm: () => void,
opts?: { title?: string; confirmText?: string; variant?: "primary" | "danger" | "success" }
) => {
setConfirmModalState({
open: true,
message,
title: opts?.title,
confirmText: opts?.confirmText,
variant: opts?.variant,
onConfirm: () => {
setConfirmModalState((s) => ({ ...s, open: false }));
onConfirm();
},
});
};
/* ---- Last Process / Warehouse ---- */
const [isLastProcess, setIsLastProcess] = useState(false);
@@ -799,25 +825,30 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
};
/* ---- Confirm Result ---- */
const handleConfirmResult = async () => {
if (!confirm("실적을 확정하시겠습니까?\n확정 후에는 추가 등록이 불가합니다.")) return;
setSaving(true);
try {
const res = await apiClient.post("/pop/production/confirm-result", {
work_order_process_id: processId,
});
if (res.data?.success) {
await fetchProcess();
alert("실적이 확정되었습니다.");
} else {
alert(res.data?.message || "확정 실패");
}
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } };
alert(err.response?.data?.message || "확정 중 오류");
} finally {
setSaving(false);
}
const handleConfirmResult = () => {
askConfirm(
"실적을 확정하시겠습니까?\n확정 후에는 추가 등록이 불가합니다.",
async () => {
setSaving(true);
try {
const res = await apiClient.post("/pop/production/confirm-result", {
work_order_process_id: processId,
});
if (res.data?.success) {
await fetchProcess();
alert("실적이 확정되었습니다.");
} else {
alert(res.data?.message || "확정 실패");
}
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } };
alert(err.response?.data?.message || "확정 중 오류");
} finally {
setSaving(false);
}
},
{ title: "실적 확정", confirmText: "확정", variant: "success" }
);
};
/* ================================================================ */
@@ -837,39 +868,44 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
}
}, []);
const handleInbound = async () => {
const handleInbound = () => {
if (!selectedWarehouse) {
alert("창고를 선택해주세요.");
return;
}
if (!confirm("생산입고를 진행하시겠습니까?")) return;
setSaving(true);
try {
const wh = warehouses.find((w) => w.id === selectedWarehouse);
const warehouseCode = wh?.warehouse_code || selectedWarehouse;
const res = await apiClient.post("/pop/production/inventory-inbound", {
work_order_process_id: processId,
warehouse_code: warehouseCode,
location_code: selectedLocation || undefined,
});
if (res.data?.success) {
setInboundDone(true);
alert(`재고 입고 완료: ${res.data.data?.qty || 0}`);
} else {
alert(res.data?.message || "입고 실패");
}
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string }; status?: number } };
const msg = err.response?.data?.message;
if (err.response?.status === 409) {
setInboundDone(true);
alert(msg || "이미 입고 완료");
} else {
alert(msg || "입고 중 오류");
}
} finally {
setSaving(false);
}
askConfirm(
"생산입고를 진행하시겠습니까?",
async () => {
setSaving(true);
try {
const wh = warehouses.find((w) => w.id === selectedWarehouse);
const warehouseCode = wh?.warehouse_code || selectedWarehouse;
const res = await apiClient.post("/pop/production/inventory-inbound", {
work_order_process_id: processId,
warehouse_code: warehouseCode,
location_code: selectedLocation || undefined,
});
if (res.data?.success) {
setInboundDone(true);
alert(`재고 입고 완료: ${res.data.data?.qty || 0}`);
} else {
alert(res.data?.message || "입고 실패");
}
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string }; status?: number } };
const msg = err.response?.data?.message;
if (err.response?.status === 409) {
setInboundDone(true);
alert(msg || "이미 입고 완료");
} else {
alert(msg || "입고 중 오류");
}
} finally {
setSaving(false);
}
},
{ title: "생산 입고", confirmText: "입고", variant: "primary" }
);
};
/* ================================================================ */
@@ -1460,20 +1496,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
{saving ? "저장중..." : (totalProduced + productionQty >= inputQty && inputQty > 0) ? "작업 완료" : "분할 완료"}
</button>
{totalProduced > 0 && (
<button
onClick={handleConfirmResult}
disabled={saving}
className="w-full h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
background: saving
? "#9ca3af"
: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
{saving ? "처리중..." : "공정 확정"}
</button>
)}
{/* 공정 확정 버튼 제거 (CEO 결정 2026-04-09): 작업 완료/분할 완료로 충분 */}
</div>
{/* Batch History */}
@@ -1722,35 +1745,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
{isConfirmed ? "실적 확정 완료" : "공정 완료"}
</span>
</div>
) : (
<>
<button
onClick={() => {
setActiveSection("result");
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
disabled={saving}
className="flex-1 h-12 rounded-xl text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
background: saving ? "#9ca3af" : "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)",
}}
>
</button>
{totalProduced > 0 && (
<button
onClick={handleConfirmResult}
disabled={saving}
className="flex-1 h-12 rounded-xl text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
background: saving ? "#9ca3af" : "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
</button>
)}
</>
)}
) : null}
</div>
{/* ============================================================ */}
@@ -1774,6 +1769,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
initialEntries={defectEntries}
processList={processList}
/>
<ConfirmModal
open={confirmModalState.open}
title={confirmModalState.title}
message={confirmModalState.message}
confirmText={confirmModalState.confirmText}
variant={confirmModalState.variant}
onConfirm={confirmModalState.onConfirm}
onCancel={() => setConfirmModalState((s) => ({ ...s, open: false }))}
/>
</div>
);
}
@@ -2165,17 +2170,22 @@ function MaterialInputSection({ processId }: { processId: string }) {
}>>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [defaultWarehouseCode, setDefaultWarehouseCode] = React.useState<string>("");
React.useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [bomRes, inputRes] = await Promise.all([
const [bomRes, inputRes, whRes] = await Promise.all([
apiClient.get(`/pop/production/bom-materials/${processId}`),
apiClient.get(`/pop/production/material-inputs/${processId}`),
apiClient.get("/pop/production/warehouses"),
]);
setBomMaterials(bomRes.data?.data?.materials || []);
setInputted(inputRes.data?.data || []);
// 첫 번째 창고를 기본 자재 출고 창고로 사용 (재고 차감용)
const wh = whRes.data?.data?.[0];
if (wh?.warehouse_code) setDefaultWarehouseCode(wh.warehouse_code);
} catch { /* non-critical */ }
setLoading(false);
};
@@ -2196,6 +2206,9 @@ function MaterialInputSection({ processId }: { processId: string }) {
unit: m.unit,
bom_detail_id: m.id,
required_qty: m.required_qty,
// 재고 차감을 위한 창고 코드 (기본 창고)
warehouse_code: defaultWarehouseCode || undefined,
location_code: defaultWarehouseCode || undefined,
}));
if (inputs.length === 0) {
@@ -8,6 +8,7 @@ import { dataApi } from "@/lib/api/data";
import { AcceptProcessModal } from "./AcceptProcessModal";
import { ProcessDetailModal, ProcessStep } from "./ProcessDetailModal";
import { ProcessWork } from "./ProcessWork";
import { ConfirmModal } from "../common/ConfirmModal";
/* 텍스트가 넘칠 때 자동 슬라이드 (마키) */
function AutoScrollText({ children, className }: { children: React.ReactNode; className?: string }) {
@@ -774,6 +775,11 @@ export function WorkOrderList() {
}>({ open: false, processId: "", processName: "", seqNo: "", maxQty: 0 });
const [acceptLoading, setAcceptLoading] = useState(false);
const [cancelConfirm, setCancelConfirm] = useState<{
open: boolean;
processId: string;
}>({ open: false, processId: "" });
/* Process Detail Modal */
const [detailModal, setDetailModal] = useState<{
open: boolean;
@@ -1526,22 +1532,9 @@ export function WorkOrderList() {
)}
{proc.status === "in_progress" && parseInt(proc.total_production_qty || "0", 10) === 0 && proc.parent_process_id && (
<button
onClick={async (e) => {
onClick={(e) => {
e.stopPropagation();
if (!confirm("접수를 취소하시겠습니까?")) return;
try {
const res = await apiClient.post("/pop/production/cancel-accept", {
work_order_process_id: proc.id,
});
if (res.data?.success) {
fetchAll();
} else {
alert(res.data?.message || "취소 실패");
}
} catch (err: unknown) {
const e2 = err as { response?: { data?: { message?: string } } };
alert(e2.response?.data?.message || "취소 중 오류");
}
setCancelConfirm({ open: true, processId: proc.id });
}}
className="w-full py-4 text-base font-bold text-red-500 border-t-2 border-red-100 bg-red-50/50 active:scale-[0.98] transition-all"
>
@@ -1605,6 +1598,34 @@ export function WorkOrderList() {
onClose={() => { setWorkModalProcessId(null); fetchAll(); }}
/>
)}
{/* Cancel accept confirm modal */}
<ConfirmModal
open={cancelConfirm.open}
title="접수 취소"
message="접수를 취소하시겠습니까?"
confirmText="취소"
cancelText="닫기"
variant="danger"
onConfirm={async () => {
const pid = cancelConfirm.processId;
setCancelConfirm({ open: false, processId: "" });
try {
const res = await apiClient.post("/pop/production/cancel-accept", {
work_order_process_id: pid,
});
if (res.data?.success) {
fetchAll();
} else {
alert(res.data?.message || "취소 실패");
}
} catch (err: unknown) {
const e2 = err as { response?: { data?: { message?: string } } };
alert(e2.response?.data?.message || "취소 중 오류");
}
}}
onCancel={() => setCancelConfirm({ open: false, processId: "" })}
/>
</div>
);
}
@@ -0,0 +1,542 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "../inventory/DateRangePicker";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InspectionRow {
id: string;
inspectionNumber: string;
itemCode: string;
itemName: string;
inspectionType: string;
totalQty: number;
goodQty: number;
badQty: number;
passRate: number;
overallJudgment: string;
defectDescription: string;
referenceTable: string;
referenceId: string;
memo: string;
inspector: string;
supplierCode: string;
supplierName: string;
isCompleted: string;
completedDate: string;
createdDate: string;
time: string;
date: string;
fullDate: string;
}
interface DetailRow {
inspectionItemName: string;
inspectionStandard: string;
passCriteria: string;
measuredValue: string;
judgment: string;
}
interface KpiData {
total: number;
pass: number;
fail: number;
waiting: number;
passRate: number;
}
type TabKey = "all" | "incoming" | "process" | "outgoing";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getJudgmentStyle(judgment: string): { color: string; label: string } {
if (judgment === "합격" || judgment === "pass") return { color: "text-green-600 bg-green-50", label: "합격" };
if (judgment === "불합격" || judgment === "fail") return { color: "text-red-600 bg-red-50", label: "불합격" };
return { color: "text-amber-600 bg-amber-50", label: "대기" };
}
function classifyTab(inspectionType: string): TabKey {
if (inspectionType?.includes("입고")) return "incoming";
if (inspectionType?.includes("공정") || inspectionType?.includes("생산")) return "process";
if (inspectionType?.includes("출하") || inspectionType?.includes("출고")) return "outgoing";
return "all";
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InspectionList() {
const router = useRouter();
const [dateFrom, setDateFrom] = useState(() => new Date().toISOString().slice(0, 10));
const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10));
const [keyword, setKeyword] = useState("");
const [judgmentFilter, setJudgmentFilter] = useState("전체");
const [items, setItems] = useState<InspectionRow[]>([]);
const [kpi, setKpi] = useState<KpiData>({ total: 0, pass: 0, fail: 0, waiting: 0, passRate: 0 });
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [selectedItem, setSelectedItem] = useState<InspectionRow | null>(null);
const [selectedDetails, setSelectedDetails] = useState<DetailRow[]>([]);
/* Fetch data — 마스터 (inspection_result_mng) */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post("/table-management/tables/inspection_result_mng/data", {
page: 1,
pageSize: 500,
});
const rows: any[] = res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
const filtered = rows.filter((r: any) => {
const d = (r.inspection_date || r.created_date || "").slice(0, 10);
if (!d) return true;
if (dateFrom && d < dateFrom) return false;
if (dateTo && d > dateTo) return false;
return true;
});
const mapped: InspectionRow[] = filtered.map((r: any, idx: number) => {
const overall = r.overall_judgment || "";
const totalQ = Number(r.total_qty || 0);
const goodQ = Number(r.good_qty || 0);
const passRate = totalQ > 0 ? Math.round((goodQ / totalQ) * 100) : 0;
return {
id: `${r.id || idx}`,
inspectionNumber: r.inspection_number || "",
itemCode: r.item_code || "",
itemName: r.item_name || "-",
inspectionType: r.inspection_type || "",
totalQty: totalQ,
goodQty: goodQ,
badQty: Number(r.bad_qty || 0),
passRate,
overallJudgment: overall,
defectDescription: r.defect_description || "",
referenceTable: r.reference_table || "",
referenceId: r.reference_id || "",
memo: r.memo || "",
inspector: r.inspector || r.writer || "",
supplierCode: r.supplier_code || "",
supplierName: r.supplier_name || "",
isCompleted: r.is_completed || "N",
completedDate: r.completed_date || "",
createdDate: r.created_date || "",
time: r.inspection_date ? new Date(r.inspection_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--",
date: (r.inspection_date || r.created_date || "").slice(0, 10),
fullDate: r.inspection_date ? new Date(r.inspection_date).toLocaleString("ko-KR") : "-",
};
});
setItems(mapped);
const total = mapped.length;
const pass = mapped.filter((m) => m.overallJudgment === "합격").length;
const fail = mapped.filter((m) => m.overallJudgment === "불합격").length;
const waiting = total - pass - fail;
const passRate = total > 0 ? Math.round((pass / total) * 100) : 0;
setKpi({ total, pass, fail, waiting, passRate });
} catch {
setItems([]);
setKpi({ total: 0, pass: 0, fail: 0, waiting: 0, passRate: 0 });
} finally {
setLoading(false);
}
}, [dateFrom, dateTo]);
/* Fetch detail when selected */
useEffect(() => {
if (!selectedItem) {
setSelectedDetails([]);
return;
}
apiClient.post("/table-management/tables/inspection_result/data", {
page: 1,
pageSize: 100,
filters: { master_id: selectedItem.id },
}).then((res) => {
const rows: any[] = res.data?.data?.data ?? res.data?.data ?? [];
const details: DetailRow[] = rows
.filter((r: any) => r.master_id === selectedItem.id)
.map((r: any) => ({
inspectionItemName: r.inspection_item_name || "-",
inspectionStandard: r.inspection_standard || r.pass_criteria || "-",
passCriteria: r.pass_criteria || "-",
measuredValue: r.measured_value || "-",
judgment: r.judgment || "",
}));
setSelectedDetails(details);
}).catch(() => setSelectedDetails([]));
}, [selectedItem]);
useEffect(() => {
fetchData();
}, [fetchData]);
/* Filter */
const filtered = items.filter((item) => {
if (activeTab !== "all") {
const tab = classifyTab(item.inspectionType);
if (tab !== activeTab) return false;
}
if (keyword) {
const kw = keyword.toLowerCase();
if (!item.itemName.toLowerCase().includes(kw) && !item.itemCode.toLowerCase().includes(kw)) return false;
}
if (judgmentFilter !== "전체") {
const j = item.overallJudgment;
if (judgmentFilter === "합격" && !(j === "합격" || j === "pass")) return false;
if (judgmentFilter === "불합격" && !(j === "불합격" || j === "fail")) return false;
if (judgmentFilter === "대기" && (j === "합격" || j === "pass" || j === "불합격" || j === "fail")) return false;
}
return true;
});
// 탭별 카운트
const counts = {
all: items.length,
incoming: items.filter((i) => classifyTab(i.inspectionType) === "incoming").length,
process: items.filter((i) => classifyTab(i.inspectionType) === "process").length,
outgoing: items.filter((i) => classifyTab(i.inspectionType) === "outgoing").length,
};
const TABS: { key: TabKey; label: string; count: number }[] = [
{ key: "all", label: "전체", count: counts.all },
{ key: "incoming", label: "입고검사", count: counts.incoming },
{ key: "process", label: "공정검사", count: counts.process },
{ key: "outgoing", label: "출하검사", count: counts.outgoing },
];
return (
<div className="flex flex-col gap-4">
{/* Back + Title */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/quality")}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="flex items-end gap-2">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2">
<DateRangePicker from={dateFrom} to={dateTo} onChange={(f, t) => { setDateFrom(f); setDateTo(t); }} />
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"> / </label>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="품목명 또는 검사번호"
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-violet-400"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<select
value={judgmentFilter}
onChange={(e) => setJudgmentFilter(e.target.value)}
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-violet-400 bg-white"
>
<option value="전체"></option>
<option value="합격"></option>
<option value="불합격"></option>
<option value="대기"></option>
</select>
</div>
</div>
<div className="flex gap-1.5 shrink-0 pb-[1px]">
<button
onClick={fetchData}
className="h-[42px] px-4 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all"
style={{ background: "linear-gradient(135deg,#8b5cf6,#6d28d9)" }}
>
</button>
<button
onClick={() => { setDateFrom(new Date().toISOString().slice(0, 10)); setDateTo(new Date().toISOString().slice(0, 10)); setKeyword(""); setJudgmentFilter("전체"); }}
className="h-[42px] w-[42px] rounded-lg text-sm font-semibold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
>
</button>
</div>
</div>
</div>
{/* KPI */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="grid grid-cols-5 gap-0">
<KpiCell icon="📋" value={loading ? "-" : kpi.total.toLocaleString()} label="전체" color="text-gray-900" />
<KpiCell icon="✅" value={loading ? "-" : kpi.pass.toLocaleString()} label="합격" color="text-green-600" />
<KpiCell icon="❌" value={loading ? "-" : kpi.fail.toLocaleString()} label="불합격" color="text-red-600" />
<KpiCell icon="⏳" value={loading ? "-" : kpi.waiting.toLocaleString()} label="대기" color="text-amber-600" />
<KpiCell icon="📊" value={loading ? "-" : `${kpi.passRate}%`} label="합격률" color="text-blue-600" />
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
activeTab === tab.key
? "text-white shadow-sm"
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
}`}
style={
activeTab === tab.key
? { background: "linear-gradient(135deg,#8b5cf6,#6d28d9)" }
: undefined
}
>
{tab.label} {tab.count}
</button>
))}
</div>
{/* List */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold text-gray-500"> </span>
<span className="text-xs text-gray-400"> {filtered.length}</span>
</div>
{loading ? (
<div className="flex flex-col gap-3 py-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-2xl border border-gray-100 p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gray-100" />
<div className="flex-1 flex flex-col gap-2">
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-3 bg-gray-50 rounded w-1/2" />
</div>
<div className="h-5 w-12 bg-gray-100 rounded-full" />
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
<p className="text-sm font-medium text-gray-500 mb-1"> </p>
<p className="text-xs text-gray-400"> / </p>
</div>
) : (
filtered.map((item) => {
const js = getJudgmentStyle(item.overallJudgment);
return (
<div
key={item.id}
onClick={() => setSelectedItem(item)}
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]"
>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0"
style={{ background: "linear-gradient(135deg,#8b5cf6,#6d28d9)" }}
>
🔍
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold text-violet-600">{item.inspectionNumber}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${js.color}`}>
{js.label}
</span>
</div>
<div className="text-sm font-bold text-gray-900 truncate mt-0.5">
{item.itemName}
{item.itemCode ? ` (${item.itemCode})` : ""}
</div>
<div className="text-xs text-gray-400 mt-0.5 truncate">
{item.inspectionType}
{item.supplierName ? ` · ${item.supplierName}` : ""}
</div>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-bold text-gray-700">
<span className="text-green-600">{item.goodQty}</span>
<span className="text-gray-300 mx-0.5">/</span>
<span className="text-red-600">{item.badQty}</span>
</p>
<p className="text-[10px] text-violet-600 font-semibold mt-0.5">{item.passRate}%</p>
<p className="text-[10px] text-gray-400">{item.time}</p>
</div>
</div>
</div>
);
})
)}
</div>
{/* Detail Bottom Sheet */}
{selectedItem && (
<div className="fixed inset-0 z-50" onClick={() => setSelectedItem(null)}>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-lg bg-white rounded-t-3xl shadow-2xl overflow-y-auto z-10"
style={{ maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white pt-3 pb-2 flex justify-center rounded-t-3xl z-10">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
<div className="flex items-center justify-between px-5 pb-4 border-b border-gray-100">
<h3 className="text-lg font-bold text-gray-900">{selectedItem.inspectionType} {selectedItem.inspectionNumber}</h3>
<button onClick={() => setSelectedItem(null)} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-5 py-4 space-y-5">
<div className="grid grid-cols-2 gap-4">
<DetailField label="검사번호" value={selectedItem.inspectionNumber} />
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1"></p>
<span className="inline-block text-xs font-bold px-2.5 py-1 rounded-full bg-violet-50 text-violet-700">
{selectedItem.inspectionType}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<DetailField label="검사일시" value={selectedItem.fullDate} />
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1"></p>
<span className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${getJudgmentStyle(selectedItem.overallJudgment).color}`}>
{getJudgmentStyle(selectedItem.overallJudgment).label}
</span>
</div>
</div>
<div className="border-t border-gray-100" />
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1"></p>
<p className="text-base font-bold text-gray-900">
{selectedItem.itemName}
{selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<DetailField label="거래처" value={selectedItem.supplierName || "-"} />
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1"></p>
<p className="text-lg font-bold text-violet-600">{selectedItem.passRate}%</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1"></p>
<p className="text-lg font-bold text-gray-900">{selectedItem.totalQty.toLocaleString()}</p>
</div>
<div>
<p className="text-[11px] font-semibold text-green-600 mb-1"></p>
<p className="text-lg font-bold text-green-600">{selectedItem.goodQty.toLocaleString()}</p>
</div>
<div>
<p className="text-[11px] font-semibold text-red-600 mb-1"></p>
<p className="text-lg font-bold text-red-600">{selectedItem.badQty.toLocaleString()}</p>
</div>
</div>
{selectedItem.defectDescription && (
<DetailField label="불량내용" value={selectedItem.defectDescription} />
)}
<DetailField label="검사자" value={selectedItem.inspector || "-"} />
{selectedItem.memo && (
<DetailField label="비고" value={selectedItem.memo} />
)}
{/* 검사 항목별 결과 (디테일) */}
{selectedDetails.length > 0 && (
<div>
<p className="text-sm font-bold text-gray-900 mb-2"> </p>
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
{selectedDetails.map((d, idx) => {
const dj = getJudgmentStyle(d.judgment);
return (
<div key={idx} className="bg-white rounded-lg p-3 border border-gray-100">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold text-gray-900">{d.inspectionItemName}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${dj.color}`}>
{dj.label}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-400"></span>
<p className="text-gray-700">{d.inspectionStandard}</p>
</div>
<div>
<span className="text-gray-400"></span>
<p className="text-gray-700 font-semibold">{d.measuredValue}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
<div className="px-5 py-4 border-t border-gray-100">
<button onClick={() => setSelectedItem(null)} className="w-full py-3 rounded-xl text-sm font-bold text-gray-600 bg-gray-100 active:scale-95 transition-all">
</button>
</div>
</div>
</div>
)}
</div>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">{label}</p>
<p className="text-sm font-semibold text-gray-900 break-all">{value}</p>
</div>
);
}
function KpiCell({ icon, value, label, color }: { icon: string; value: string; label: string; color: string }) {
return (
<div className="flex flex-col items-center py-2">
<span className="text-lg mb-0.5">{icon}</span>
<span
className={`text-xl sm:text-2xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{value}
</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">{label}</span>
</div>
);
}
@@ -0,0 +1,219 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface RecentItem {
id: string;
itemName: string;
itemCode: string;
inspectionType: string;
judgment: string;
judgmentColor: string;
judgmentLabel: string;
time: string;
}
interface KpiData {
todayTotal: number;
todayPass: number;
todayFail: number;
passRate: number;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getJudgmentStyle(j: string): { color: string; label: string } {
if (j === "합격" || j === "pass") return { color: "text-green-600 bg-green-50", label: "합격" };
if (j === "불합격" || j === "fail") return { color: "text-red-600 bg-red-50", label: "불합격" };
return { color: "text-amber-600 bg-amber-50", label: "대기" };
}
const MENU_ITEMS = [
{
id: "inspection",
title: "검사관리",
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
shadowColor: "rgba(139,92,246,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z" />
</svg>
),
href: "/pop/quality/inspection",
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function QualityHome() {
const router = useRouter();
const [kpi, setKpi] = useState<KpiData>({ todayTotal: 0, todayPass: 0, todayFail: 0, passRate: 0 });
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const today = new Date().toISOString().slice(0, 10);
const res = await apiClient.post("/table-management/tables/inspection_result_mng/data", {
page: 1,
pageSize: 500,
});
const rows: any[] = res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
const todayRows = rows.filter((r: any) => (r.created_date || "").slice(0, 10) === today);
const total = todayRows.length;
const pass = todayRows.filter((r: any) => r.overall_judgment === "합격" || r.overall_judgment === "pass").length;
const fail = todayRows.filter((r: any) => r.overall_judgment === "불합격" || r.overall_judgment === "fail").length;
const passRate = total > 0 ? Math.round((pass / total) * 100) : 0;
setKpi({ todayTotal: total, todayPass: pass, todayFail: fail, passRate });
// 최근 5건
const sorted = [...rows].sort((a: any, b: any) => (b.created_date || "").localeCompare(a.created_date || ""));
const top5 = sorted.slice(0, 5).map((r: any, idx: number) => {
const js = getJudgmentStyle(r.overall_judgment || r.judgment || "");
return {
id: `${r.id || idx}`,
itemName: r.item_name || "-",
itemCode: r.item_code || "",
inspectionType: r.inspection_type || "",
judgment: r.overall_judgment || "",
judgmentColor: js.color,
judgmentLabel: js.label,
time: r.created_date ? new Date(r.created_date).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) : "--:--",
};
});
setRecentItems(top5);
} catch {
// empty
} finally {
setLoading(false);
}
};
fetchData();
}, []);
return (
<div className="flex flex-col gap-5">
{/* Back + Title */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/home")}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight"></h1>
<p className="text-xs text-gray-400 mt-0.5"> </p>
</div>
</div>
{/* KPI */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<div className="grid grid-cols-4 gap-0">
<KpiCell value={loading ? "-" : kpi.todayTotal.toLocaleString()} label="금일 검사" color="text-gray-900" />
<KpiCell value={loading ? "-" : kpi.todayPass.toLocaleString()} label="합격" color="text-green-600" />
<KpiCell value={loading ? "-" : kpi.todayFail.toLocaleString()} label="불합격" color="text-red-600" />
<KpiCell value={loading ? "-" : `${kpi.passRate}%`} label="합격률" color="text-violet-600" />
</div>
</div>
{/* Menu Icons */}
<section>
<div className="flex items-center gap-2 mb-3">
<div className="w-1 h-5 rounded-full bg-violet-500" />
<h2 className="text-base sm:text-lg font-bold text-gray-900"> </h2>
</div>
<div className="flex flex-wrap justify-start gap-x-5 gap-y-4 sm:gap-x-6 sm:gap-y-5">
{MENU_ITEMS.map((item) => (
<div
key={item.id}
className="flex flex-col items-center gap-2 w-16 sm:w-[72px] cursor-pointer group"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => router.push(item.href)}
>
<div
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-105 group-active:scale-[0.93]"
style={{ background: item.gradient, boxShadow: `0 4px 12px ${item.shadowColor}` }}
>
{item.icon}
</div>
<span className="text-[11px] sm:text-xs font-semibold text-gray-700 text-center leading-tight">
{item.title}
</span>
</div>
))}
</div>
</section>
{/* Recent Activity */}
<section>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-100">
<h3 className="text-base sm:text-lg font-bold text-gray-900"> </h3>
<span className="text-xs text-gray-400"> 5</span>
</div>
<div className="flex flex-col gap-2">
{loading ? (
<div className="text-center py-8 text-sm text-gray-400"> ...</div>
) : recentItems.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400"> </div>
) : (
recentItems.map((item) => (
<div key={item.id} className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors">
<span className="text-xs font-semibold text-gray-400 min-w-[44px] text-right" style={{ fontVariantNumeric: "tabular-nums" }}>
{item.time}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900 truncate">
{item.itemName}
{item.itemCode ? ` (${item.itemCode})` : ""}
</span>
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full shrink-0 ${item.judgmentColor}`}>
{item.judgmentLabel}
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5 truncate">{item.inspectionType}</div>
</div>
</div>
))
)}
</div>
</div>
</section>
</div>
);
}
function KpiCell({ value, label, color }: { value: string; label: string; color: string }) {
return (
<div className="flex flex-col items-center py-2">
<span
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
>
{value}
</span>
<span className="text-[11px] font-medium text-gray-400 mt-1">{label}</span>
</div>
);
}
@@ -0,0 +1,2 @@
export { QualityHome } from "./QualityHome";
export { InspectionList } from "./InspectionList";
+23 -3
View File
@@ -51,6 +51,7 @@ export interface UseCartSyncReturn {
addItem: (item: CartItem, rowKey: string) => void;
removeItem: (rowKey: string) => void;
updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void;
updateItemRow: (rowKey: string, partialRow: Record<string, unknown>) => void;
isItemInCart: (rowKey: string) => boolean;
getCartItem: (rowKey: string) => CartItemWithId | undefined;
@@ -137,7 +138,7 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
const serialize = (items: CartItemWithId[]) =>
items
.map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}`)
.map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}:${JSON.stringify(item.row)}`)
.sort()
.join("|");
@@ -249,6 +250,20 @@ export function useCartSync(
[],
);
// row 객체에 임의 필드를 부분 업데이트 (예: inspectionResult)
const updateItemRow = useCallback(
(rowKey: string, partialRow: Record<string, unknown>) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey
? { ...i, row: { ...i.row, ...partialRow } }
: i,
),
);
},
[],
);
const isItemInCart = useCallback(
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
[cartItems],
@@ -272,7 +287,9 @@ export function useCartSync(
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status;
// row JSON 비교 (검사 결과 등 포함)
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status || rowChanged;
});
return {
@@ -301,10 +318,12 @@ export function useCartSync(
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status
c.status !== saved.status ||
rowChanged
);
});
@@ -354,6 +373,7 @@ export function useCartSync(
addItem,
removeItem,
updateItemQuantity,
updateItemRow,
isItemInCart,
getCartItem,
getChanges,