feat: POP 시연 준비 — 5개 화면 + 버그 수정 + 자동 창고 매칭

- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단
- 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선
- 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거
- 검사관리+입출고관리: 신규 화면 (quality, inventory)
- 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
This commit is contained in:
SeongHyun Kim
2026-04-09 14:38:28 +09:00
parent 1b62dae277
commit 327b4d01c2
25 changed files with 15182 additions and 12185 deletions
@@ -7,9 +7,9 @@
* - 기타출고 → item_info (품목)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
// 출고 목록 조회
@@ -50,7 +50,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
if (search_keyword) {
conditions.push(
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`,
);
params.push(`%${search_keyword}%`);
paramIdx++;
@@ -105,10 +105,20 @@ export async function create(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body;
const {
items,
outbound_number,
outbound_date,
warehouse_code,
location_code,
manager_id,
memo,
} = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
return res
.status(400)
.json({ success: false, message: "출고 품목이 없습니다." });
}
await client.query("BEGIN");
@@ -168,7 +178,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
item.delivery_destination || null,
item.delivery_address || null,
userId,
]
],
);
insertedRows.push(result.rows[0]);
@@ -187,12 +197,12 @@ export async function create(req: AuthenticatedRequest, res: Response) {
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
[companyCode, itemCode, whCode || "", locCode || ""],
);
const currentStock = parseFloat(stockCheck.rows[0]?.cur || '0');
const currentStock = parseFloat(stockCheck.rows[0]?.cur || "0");
if (currentStock < outQty) {
throw new Error(
`재고 부족: ${item.item_name || itemCode} (창고 ${whCode || '미지정'}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`
`재고 부족: ${item.item_name || itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`,
);
}
@@ -202,7 +212,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
[companyCode, itemCode, whCode || "", locCode || ""],
);
if (existingStock.rows.length > 0) {
@@ -212,7 +222,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
last_out_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[outQty, existingStock.rows[0].id]
[outQty, existingStock.rows[0].id],
);
} else {
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
@@ -222,7 +232,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
current_qty, safety_qty, last_out_date,
created_date, updated_date, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
[companyCode, itemCode, whCode, locCode, userId]
[companyCode, itemCode, whCode, locCode, userId],
);
}
@@ -233,34 +243,47 @@ export async function create(req: AuthenticatedRequest, res: Response) {
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
[companyCode, itemCode, whCode || "", locCode || ""],
);
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
const afterQty = afterStockRes.rows[0]?.current_qty || "0";
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_code, warehouse_code, location_code,
transaction_type, transaction_date, quantity, balance_qty, remark,
writer, created_date
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW(), $5, $6, $7, $8, NOW())`,
[companyCode, itemCode, whCode, locCode, String(-outQty), afterQty, item.outbound_type || '출고', userId]
[
companyCode,
itemCode,
whCode,
locCode,
String(-outQty),
afterQty,
item.outbound_type || "출고",
userId,
],
);
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
if (
item.outbound_type === "판매출고" &&
item.source_id &&
item.source_type === "shipment_instruction_detail"
) {
const outQtyNum = Number(item.outbound_qty) || 0;
await client.query(
`UPDATE shipment_instruction_detail
SET ship_qty = COALESCE(ship_qty, 0) + $1,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[outQtyNum, item.source_id, companyCode]
[outQtyNum, item.source_id, companyCode],
);
// 출하지시 상세의 detail_id로 수주상세(sales_order_detail) ship_qty도 업데이트
const sidRes = await client.query(
`SELECT detail_id FROM shipment_instruction_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
[item.source_id, companyCode],
);
const detailId = sidRes.rows[0]?.detail_id;
if (detailId) {
@@ -270,7 +293,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[outQtyNum, detailId, companyCode]
[outQtyNum, detailId, companyCode],
);
}
}
@@ -306,9 +329,16 @@ export async function update(req: AuthenticatedRequest, res: Response) {
const userId = req.user!.userId;
const { id } = req.params;
const {
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id: mgr, memo,
outbound_date,
outbound_qty,
unit_price,
total_amount,
lot_number,
warehouse_code,
location_code,
outbound_status,
manager_id: mgr,
memo,
} = req.body;
const pool = getPool();
@@ -329,15 +359,26 @@ export async function update(req: AuthenticatedRequest, res: Response) {
WHERE id = $12 AND company_code = $13
RETURNING *`,
[
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, mgr, memo,
userId, id, companyCode,
]
outbound_date,
outbound_qty,
unit_price,
total_amount,
lot_number,
warehouse_code,
location_code,
outbound_status,
mgr,
memo,
userId,
id,
companyCode,
],
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
return res
.status(404)
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
logger.info("출고 수정", { companyCode, userId, id });
@@ -358,11 +399,13 @@ export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
const result = await pool.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
[id, companyCode],
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return res
.status(404)
.json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
logger.info("출고 삭제", { companyCode, id });
@@ -375,7 +418,10 @@ export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
}
// 판매출고용: 출하지시 데이터 조회
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
export async function getShipmentInstructions(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
@@ -386,7 +432,7 @@ export async function getShipmentInstructions(req: AuthenticatedRequest, res: Re
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`,
);
params.push(`%${keyword}%`);
paramIdx++;
@@ -417,7 +463,7 @@ export async function getShipmentInstructions(req: AuthenticatedRequest, res: Re
WHERE ${conditions.join(" AND ")}
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
ORDER BY si.instruction_date DESC, si.instruction_no`,
params
params,
);
return res.json({ success: true, data: result.rows });
@@ -428,7 +474,10 @@ export async function getShipmentInstructions(req: AuthenticatedRequest, res: Re
}
// 반품출고용: 발주(입고) 데이터 조회
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
export async function getPurchaseOrders(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
@@ -439,12 +488,12 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
// 입고된 것만 (반품 대상)
conditions.push(
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`,
);
if (keyword) {
conditions.push(
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`,
);
params.push(`%${keyword}%`);
paramIdx++;
@@ -462,7 +511,7 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
FROM purchase_order_mng
WHERE ${conditions.join(" AND ")}
ORDER BY order_date DESC, purchase_no`,
params
params,
);
return res.json({ success: true, data: result.rows });
@@ -484,7 +533,7 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`,
);
params.push(`%${keyword}%`);
paramIdx++;
@@ -498,7 +547,7 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
params,
);
return res.json({ success: true, data: result.rows });
@@ -512,16 +561,25 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const ruleId = (req.query.ruleId as string) || (req.query.rule_id as string);
const ruleId =
(req.query.ruleId as string) || (req.query.rule_id as string);
// 1순위: POP 화면설정에서 선택한 채번규칙 사용
if (ruleId && ruleId !== "__none__") {
try {
const { numberingRuleService } = await import("../services/numberingRuleService");
const newNumber = await numberingRuleService.allocateCode(ruleId, companyCode);
const { numberingRuleService } = await import(
"../services/numberingRuleService"
);
const newNumber = await numberingRuleService.allocateCode(
ruleId,
companyCode,
);
return res.json({ success: true, data: newNumber });
} catch (e: any) {
logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message });
logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", {
ruleId,
error: e.message,
});
}
}
@@ -535,7 +593,7 @@ export async function generateNumber(req: AuthenticatedRequest, res: Response) {
`SELECT outbound_number FROM outbound_mng
WHERE company_code = $1 AND outbound_number LIKE $2
ORDER BY outbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
[companyCode, `${prefix}%`],
);
let seq = 1;
@@ -565,7 +623,7 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
FROM warehouse_info
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
ORDER BY warehouse_name`,
[companyCode]
[companyCode],
);
return res.json({ success: true, data: result.rows });
@@ -587,7 +645,7 @@ export async function getLocations(req: AuthenticatedRequest, res: Response) {
FROM warehouse_location
WHERE company_code = $1 ${warehouseCode ? "AND warehouse_code = $2" : ""}
ORDER BY location_code`,
warehouseCode ? [companyCode, warehouseCode] : [companyCode]
warehouseCode ? [companyCode, warehouseCode] : [companyCode],
);
return res.json({ success: true, data: result.rows });
File diff suppressed because it is too large Load Diff
@@ -7,22 +7,17 @@
* - 기타입고 → item_info (품목)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
// 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환)
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const {
inbound_type,
inbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const { inbound_type, inbound_status, search_keyword, date_from, date_to } =
req.query;
const conditions: string[] = [];
const params: any[] = [];
@@ -50,7 +45,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
if (search_keyword) {
conditions.push(
`(im.inbound_number ILIKE $${paramIdx} OR COALESCE(id.item_name, im.item_name) ILIKE $${paramIdx} OR COALESCE(id.item_number, im.item_number) ILIKE $${paramIdx} OR COALESCE(id.supplier_name, im.supplier_name) ILIKE $${paramIdx} OR COALESCE(id.reference_number, im.reference_number) ILIKE $${paramIdx})`
`(im.inbound_number ILIKE $${paramIdx} OR COALESCE(id.item_name, im.item_name) ILIKE $${paramIdx} OR COALESCE(id.item_number, im.item_number) ILIKE $${paramIdx} OR COALESCE(id.supplier_name, im.supplier_name) ILIKE $${paramIdx} OR COALESCE(id.reference_number, im.reference_number) ILIKE $${paramIdx})`,
);
params.push(`%${search_keyword}%`);
paramIdx++;
@@ -128,10 +123,21 @@ export async function create(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, memo } = req.body;
const {
items,
inbound_number,
inbound_date,
warehouse_code,
location_code,
inspector,
manager,
memo,
} = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "입고 품목이 없습니다." });
return res
.status(400)
.json({ success: false, message: "입고 품목이 없습니다." });
}
// 첫 번째 아이템에서 inbound_type 추출 (헤더용)
@@ -165,7 +171,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
manager || items[0].manager || null,
memo || items[0].memo || null,
userId,
]
],
);
const headerRow = headerResult.rows[0];
@@ -214,7 +220,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
item.memo || null,
item.item_id || null,
userId,
]
],
);
insertedDetails.push(detailResult.rows[0]);
@@ -231,7 +237,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
[companyCode, itemCode, whCode || "", locCode || ""],
);
if (existingStock.rows.length > 0) {
@@ -241,7 +247,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[inQty, existingStock.rows[0].id]
[inQty, existingStock.rows[0].id],
);
} else {
await client.query(
@@ -250,7 +256,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
current_qty, safety_qty, last_in_date,
created_date, updated_date, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
[companyCode, itemCode, whCode, locCode, String(inQty), userId],
);
}
@@ -261,7 +267,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
[companyCode, itemCode, whCode || "", locCode || ""],
);
const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty);
await client.query(
@@ -270,12 +276,25 @@ export async function create(req: AuthenticatedRequest, res: Response) {
transaction_type, transaction_date, quantity, balance_qty, remark,
writer, created_date
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW(), $5, $6, $7, $8, NOW())`,
[companyCode, itemCode, whCode, locCode, String(inQty), afterQty, item.inbound_type || '입고', userId]
[
companyCode,
itemCode,
whCode,
locCode,
String(inQty),
afterQty,
item.inbound_type || "입고",
userId,
],
);
}
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
if (
item.inbound_type === "구매입고" &&
item.source_id &&
item.source_table === "purchase_order_mng"
) {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
@@ -293,12 +312,16 @@ export async function create(req: AuthenticatedRequest, res: Response) {
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode]
[item.inbound_qty || 0, item.source_id, companyCode],
);
}
// 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") {
if (
item.inbound_type === "구매입고" &&
item.source_id &&
item.source_table === "purchase_detail"
) {
// 1. 해당 purchase_detail의 received_qty 누적 업데이트
await client.query(
`UPDATE purchase_detail SET
@@ -312,13 +335,13 @@ export async function create(req: AuthenticatedRequest, res: Response) {
),
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode]
[item.inbound_qty || 0, item.source_id, companyCode],
);
// 2. 발주 헤더 상태 업데이트
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
[item.source_id, companyCode],
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
@@ -329,10 +352,10 @@ export async function create(req: AuthenticatedRequest, res: Response) {
AND COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0
LIMIT 1`,
[purchaseNo, companyCode]
[purchaseNo, companyCode],
);
const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고';
// 발주 헤더의 received_qty도 디테일 합계로 동기화
const newStatus =
unreceived.rows.length === 0 ? "입고완료" : "부분입고";
await client.query(
`UPDATE purchase_order_mng SET
status = $1,
@@ -351,7 +374,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
),
updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode]
[newStatus, purchaseNo, companyCode],
);
}
}
@@ -391,10 +414,18 @@ export async function update(req: AuthenticatedRequest, res: Response) {
const userId = req.user!.userId;
const { id } = req.params;
const {
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager: mgr, memo,
inbound_date,
inbound_qty,
unit_price,
total_amount,
lot_number,
warehouse_code,
location_code,
inbound_status,
inspection_status,
inspector,
manager: mgr,
memo,
detail_id,
} = req.body;
@@ -415,15 +446,24 @@ export async function update(req: AuthenticatedRequest, res: Response) {
WHERE id = $9 AND company_code = $10
RETURNING *`,
[
inbound_date, warehouse_code, location_code,
inbound_status, inspector, mgr, memo,
userId, id, companyCode,
]
inbound_date,
warehouse_code,
location_code,
inbound_status,
inspector,
mgr,
memo,
userId,
id,
companyCode,
],
);
if (headerResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
return res
.status(404)
.json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
// 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트
@@ -442,10 +482,16 @@ export async function update(req: AuthenticatedRequest, res: Response) {
WHERE id = $8 AND company_code = $9
RETURNING *`,
[
inbound_qty, unit_price, total_amount,
lot_number, inspection_status, memo,
userId, detail_id, companyCode,
]
inbound_qty,
unit_price,
total_amount,
lot_number,
inspection_status,
memo,
userId,
detail_id,
companyCode,
],
);
detailRow = detailResult.rows[0] || null;
} else {
@@ -459,10 +505,14 @@ export async function update(req: AuthenticatedRequest, res: Response) {
inspection_status = COALESCE($5, inspection_status)
WHERE id = $6 AND company_code = $7`,
[
inbound_qty, unit_price, total_amount,
lot_number, inspection_status,
id, companyCode,
]
inbound_qty,
unit_price,
total_amount,
lot_number,
inspection_status,
id,
companyCode,
],
);
}
@@ -484,7 +534,10 @@ export async function update(req: AuthenticatedRequest, res: Response) {
}
// 입고 삭제 (헤더 + 디테일, 재고/발주 롤백 포함)
export async function deleteReceiving(req: AuthenticatedRequest, res: Response) {
export async function deleteReceiving(
req: AuthenticatedRequest,
res: Response,
) {
const pool = getPool();
const client = await pool.connect();
@@ -498,12 +551,14 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
// 헤더 정보 조회 (inbound_number, warehouse_code 등)
const headerResult = await client.query(
`SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode]
[id, companyCode],
);
if (headerResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
return res
.status(404)
.json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
const header = headerResult.rows[0];
@@ -512,11 +567,12 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
// 디테일 조회 (재고/발주 롤백용)
const detailResult = await client.query(
`SELECT * FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`,
[inboundNumber, companyCode]
[inboundNumber, companyCode],
);
// 디테일이 있으면 디테일 기반으로 롤백, 없으면 헤더(레거시) 기반으로 롤백
const rollbackItems = detailResult.rows.length > 0
const rollbackItems =
detailResult.rows.length > 0
? detailResult.rows.map((d: any) => ({
item_number: d.item_number,
inbound_qty: d.inbound_qty,
@@ -524,13 +580,15 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
source_table: header.source_table,
source_id: header.source_id,
}))
: [{
: [
{
item_number: header.item_number,
inbound_qty: header.inbound_qty,
inbound_type: header.inbound_type,
source_table: header.source_table,
source_id: header.source_id,
}];
},
];
const whCode = header.warehouse_code || null;
const locCode = header.location_code || null;
@@ -550,7 +608,7 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
WHERE company_code = $2 AND item_code = $3
AND COALESCE(warehouse_code, '') = COALESCE($4, '')
AND COALESCE(location_code, '') = COALESCE($5, '')`,
[inQty, companyCode, itemCode, whCode || '', locCode || '']
[inQty, companyCode, itemCode, whCode || "", locCode || ""],
);
// 입고취소 이력 기록
@@ -560,21 +618,33 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
[companyCode, itemCode, whCode || "", locCode || ""],
);
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
const afterQty = afterStockRes.rows[0]?.current_qty || "0";
await client.query(
`INSERT INTO inventory_history (
id, company_code, item_code, warehouse_code, location_code,
transaction_type, transaction_date, quantity, balance_qty, remark,
writer, created_date
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW(), $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`,
[companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId]
[
companyCode,
itemCode,
whCode,
locCode,
String(-inQty),
afterQty,
userId,
],
);
}
// 구매입고 발주 롤백: purchase_order_mng 기반
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
if (
item.inbound_type === "구매입고" &&
item.source_id &&
item.source_table === "purchase_order_mng"
) {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
@@ -591,15 +661,19 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[inQty, item.source_id, companyCode]
[inQty, item.source_id, companyCode],
);
}
// 구매입고 발주 롤백: purchase_detail 기반
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") {
if (
item.inbound_type === "구매입고" &&
item.source_id &&
item.source_table === "purchase_detail"
) {
const detailInfo = await client.query(
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode]
[item.source_id, companyCode],
);
if (detailInfo.rows.length > 0) {
const purchaseNo = detailInfo.rows[0].purchase_no;
@@ -617,7 +691,7 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
WHERE pd.purchase_no = $2 AND pd.company_code = $1
AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0
LIMIT 1`,
[companyCode, purchaseNo, inboundNumber]
[companyCode, purchaseNo, inboundNumber],
);
// 잔량 있으면 부분입고, 전량 미입고면 발주확정
const hasAnyReceived = await client.query(
@@ -625,15 +699,18 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
WHERE source_table = 'purchase_detail' AND company_code = $1
AND inbound_number != $2
LIMIT 1`,
[companyCode, inboundNumber]
[companyCode, inboundNumber],
);
const newStatus = hasAnyReceived.rows.length > 0
? (unreceived.rows.length === 0 ? '입고완료' : '부분입고')
: '발주확정';
const newStatus =
hasAnyReceived.rows.length > 0
? unreceived.rows.length === 0
? "입고완료"
: "부분입고"
: "발주확정";
await client.query(
`UPDATE purchase_order_mng SET status = $1, updated_date = NOW()
WHERE purchase_no = $2 AND company_code = $3`,
[newStatus, purchaseNo, companyCode]
[newStatus, purchaseNo, companyCode],
);
}
}
@@ -642,13 +719,13 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
// 디테일 삭제
await client.query(
`DELETE FROM inbound_detail WHERE inbound_id = $1 AND company_code = $2`,
[inboundNumber, companyCode]
[inboundNumber, companyCode],
);
// 헤더 삭제
await client.query(
`DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2`,
[id, companyCode]
[id, companyCode],
);
await client.query("COMMIT");
@@ -666,7 +743,10 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response)
}
// 구매입고용: 발주 데이터 조회 (미입고분) - 신규 헤더-디테일 구조 + 레거시 단일 테이블 UNION ALL
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
export async function getPurchaseOrders(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page, pageSize } = req.query;
@@ -769,13 +849,13 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response
const countResult = await pool.query(
`${baseQuery} SELECT COUNT(*) AS total FROM combined`,
params
params,
);
const totalCount = parseInt(countResult.rows[0].total, 10);
const dataResult = await pool.query(
`${baseQuery} SELECT * FROM combined ORDER BY order_date DESC, purchase_no LIMIT ${limit} OFFSET ${offset}`,
params
params,
);
return res.json({ success: true, data: dataResult.rows, totalCount });
@@ -800,7 +880,7 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`,
);
params.push(`%${keyword}%`);
paramIdx++;
@@ -815,7 +895,7 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
WHERE ${whereClause}`,
params
params,
);
const totalCount = parseInt(countResult.rows[0].total, 10);
@@ -841,7 +921,7 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
WHERE ${whereClause}
ORDER BY si.instruction_date DESC, si.instruction_no
LIMIT ${limit} OFFSET ${offset}`,
params
params,
);
return res.json({ success: true, data: dataResult.rows, totalCount });
@@ -866,7 +946,7 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`,
);
params.push(`%${keyword}%`);
paramIdx++;
@@ -883,7 +963,7 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
const countResult = await pool.query(
`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`,
params
params,
);
const totalCount = parseInt(countResult.rows[0].total, 10);
@@ -895,7 +975,7 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
WHERE ${whereClause}
ORDER BY item_name
LIMIT ${limit} OFFSET ${offset}`,
params
params,
);
return res.json({ success: true, data: dataResult.rows, totalCount });
@@ -909,16 +989,25 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const ruleId = (req.query.ruleId as string) || (req.query.rule_id as string);
const ruleId =
(req.query.ruleId as string) || (req.query.rule_id as string);
// 1순위: POP 화면설정에서 선택한 채번규칙 사용
if (ruleId && ruleId !== "__none__") {
try {
const { numberingRuleService } = await import("../services/numberingRuleService");
const newNumber = await numberingRuleService.allocateCode(ruleId, companyCode);
const { numberingRuleService } = await import(
"../services/numberingRuleService"
);
const newNumber = await numberingRuleService.allocateCode(
ruleId,
companyCode,
);
return res.json({ success: true, data: newNumber });
} catch (e: any) {
logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message });
logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", {
ruleId,
error: e.message,
});
// 폴백
}
}
@@ -933,7 +1022,7 @@ export async function generateNumber(req: AuthenticatedRequest, res: Response) {
`SELECT inbound_number FROM inbound_mng
WHERE company_code = $1 AND inbound_number LIKE $2
ORDER BY inbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
[companyCode, `${prefix}%`],
);
let seq = 1;
@@ -963,7 +1052,7 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
ORDER BY warehouse_name`,
[companyCode]
[companyCode],
);
return res.json({ success: true, data: result.rows });
@@ -1,4 +1,4 @@
import { Router, Request, Response } from "express";
import { type Request, type Response, Router } from "express";
import { getPool } from "../database/db";
import { authenticateToken } from "../middleware/authMiddleware";
@@ -97,13 +97,15 @@ router.get("/", async (req: Request, res: Response) => {
// ---- 검사번호 채번 (PC numberingRuleService 활용) ----
async function generateInspectionNumber(companyCode: string): Promise<string> {
// PC 채번 서비스 동적 import (순환 참조 방지)
const { numberingRuleService } = await import("../services/numberingRuleService");
const { numberingRuleService } = await import(
"../services/numberingRuleService"
);
// 1) inspection_result_mng / inspection_number 채번 규칙 조회
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
"inspection_result_mng",
"inspection_number"
"inspection_number",
);
if (rule && rule.ruleId) {
@@ -120,7 +122,7 @@ async function generateInspectionNumber(companyCode: string): Promise<string> {
`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}%`]
[companyCode, `${prefix}%`],
);
let nextSeq = 1;
if (result.rows.length > 0) {
@@ -180,7 +182,9 @@ router.post("/", async (req: Request, res: Response) => {
} = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "검사 항목이 없습니다" });
return res
.status(400)
.json({ success: false, message: "검사 항목이 없습니다" });
}
const client = await pool.connect();
@@ -194,17 +198,18 @@ router.post("/", async (req: Request, res: Response) => {
SELECT id FROM inspection_result_mng
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3
)`,
[companyCode, referenceId, referenceTable]
[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]
[companyCode, referenceId, referenceTable],
);
}
// 2. 검사번호 (프론트에서 미리 받았으면 재사용, 없으면 새로 채번)
const inspectionNumber = providedNumber || await generateInspectionNumber(companyCode);
const inspectionNumber =
providedNumber || (await generateInspectionNumber(companyCode));
// 3. 마스터 INSERT
const completedFlag = isCompleted ? "Y" : "N";
@@ -244,7 +249,7 @@ router.post("/", async (req: Request, res: Response) => {
supplierName || null,
completedFlag,
completedDate,
]
],
);
const masterId = masterResult.rows[0].id;
@@ -284,7 +289,7 @@ router.post("/", async (req: Request, res: Response) => {
memo || null,
completedFlag,
completedDate,
]
],
);
insertedDetailIds.push(detailResult.rows[0].id);
}
+93 -17
View File
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import type React from "react";
interface MenuIconItem {
id: string;
@@ -19,8 +19,18 @@ const MENU_ITEMS: MenuIconItem[] = [
gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
shadowColor: "rgba(59,130,246,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
),
href: "/pop/inbound",
@@ -31,8 +41,18 @@ const MENU_ITEMS: MenuIconItem[] = [
gradient: "linear-gradient(135deg,#22c55e,#15803d)",
shadowColor: "rgba(34,197,94,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0H6.75m11.25 0h2.625c.621 0 1.125-.504 1.125-1.125v-4.875c0-.621-.504-1.125-1.125-1.125H17.25m-13.5-.375V6.375c0-.621.504-1.125 1.125-1.125h7.5c.621 0 1.125.504 1.125 1.125v7.125" />
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0H6.75m11.25 0h2.625c.621 0 1.125-.504 1.125-1.125v-4.875c0-.621-.504-1.125-1.125-1.125H17.25m-13.5-.375V6.375c0-.621.504-1.125 1.125-1.125h7.5c.621 0 1.125.504 1.125 1.125v7.125"
/>
</svg>
),
href: "/pop/outbound",
@@ -43,9 +63,23 @@ const MENU_ITEMS: MenuIconItem[] = [
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
shadowColor: "rgba(245,158,11,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
href: "/pop/production",
@@ -56,8 +90,18 @@ const MENU_ITEMS: MenuIconItem[] = [
gradient: "linear-gradient(135deg,#ef4444,#b91c1c)",
shadowColor: "rgba(239,68,68,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 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 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
className="w-7 h-7 sm:w-8 sm:h-8 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 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
href: "/pop/quality",
@@ -68,8 +112,18 @@ const MENU_ITEMS: MenuIconItem[] = [
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
shadowColor: "rgba(139,92,246,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
href: "/pop/screens/equipment",
@@ -80,8 +134,18 @@ const MENU_ITEMS: MenuIconItem[] = [
gradient: "linear-gradient(135deg,#06b6d4,#0e7490)",
shadowColor: "rgba(6,182,212,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} 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
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
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>
),
href: "/pop/inventory",
@@ -93,8 +157,18 @@ const MENU_ITEMS: MenuIconItem[] = [
gradient: "linear-gradient(135deg,#f97316,#c2410c)",
shadowColor: "rgba(249,115,22,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z" />
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"
/>
</svg>
),
href: "/pop/screens/safety",
@@ -134,7 +208,9 @@ export function MenuIcons() {
>
{item.icon}
</div>
<span className="text-xs sm:text-sm font-semibold text-gray-700">{item.title}</span>
<span className="text-xs sm:text-sm font-semibold text-gray-700">
{item.title}
</span>
</div>
))}
</div>
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { InspectionModal, type InspectionResult } from "./InspectionModal";
import type { PackageEntry } from "./NumberPadModal";
@@ -77,7 +77,9 @@ export function InboundCart({
const [resultMsg, setResultMsg] = useState<string | null>(null);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionTarget, setInspectionTarget] = useState<CartItem | null>(null);
const [inspectionTarget, setInspectionTarget] = useState<CartItem | null>(
null,
);
/* Warehouse state */
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
@@ -104,7 +106,10 @@ export function InboundCart({
}, [open, fetchWarehouses]);
const totalQty = items.reduce((s, i) => s + i.inbound_qty, 0);
const totalAmount = items.reduce((s, i) => s + i.inbound_qty * i.unit_price, 0);
const totalAmount = items.reduce(
(s, i) => s + i.inbound_qty * i.unit_price,
0,
);
/* Toggle select */
const toggleSelect = (id: string) => {
@@ -136,7 +141,7 @@ export function InboundCart({
const updated = items.map((item) =>
item.id === inspectionTarget.id
? { ...item, inspectionResult: result }
: item
: item,
);
onUpdateItems(updated);
setInspectionTarget(null);
@@ -179,7 +184,9 @@ export function InboundCart({
unit: "EA",
inbound_qty: String(item.inbound_qty),
unit_price: String(item.unit_price || 0),
total_amount: String((item.inbound_qty || 0) * (item.unit_price || 0)),
total_amount: String(
(item.inbound_qty || 0) * (item.unit_price || 0),
),
reference_number: item.purchase_no,
supplier_code: item.supplier_code,
supplier_name: item.supplier_name,
@@ -198,18 +205,24 @@ export function InboundCart({
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 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 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", {
return apiClient
.post("/pop/inspection-result", {
inspectionNumber: item.inspectionResult.inspectionNumber, // 카트에서 받은 검사번호 재사용
referenceTable: "inbound_mng",
referenceId,
@@ -236,8 +249,13 @@ export function InboundCart({
measuredValue: insp.measured_value || "",
judgment: insp.result || null,
})),
}).catch((err) => {
console.error("[inspection_result 저장 실패]", item.item_code, err?.message);
})
.catch((err) => {
console.error(
"[inspection_result 저장 실패]",
item.item_code,
err?.message,
);
});
})
.filter(Boolean);
@@ -248,19 +266,24 @@ export function InboundCart({
// 3. cart_items DB 정리 (백그라운드, 논블로킹)
// cart_items.row_key 로 삭제 (row_key = source_id 로 저장됨)
const rowKeys = items.map((item) => item.source_id || item.id).filter(Boolean);
const rowKeys = items
.map((item) => item.source_id || item.id)
.filter(Boolean);
if (rowKeys.length > 0) {
apiClient.post("/pop/execute-action", {
apiClient
.post("/pop/execute-action", {
tasks: [{ type: "cart-save" }],
cartChanges: {
toDelete: rowKeys,
},
}).catch(() => {
})
.catch(() => {
// cart cleanup 실패 시 무시
});
}
const inboundNo = res.data?.data?.header?.inbound_number || inboundNumber || "";
const inboundNo =
res.data?.data?.header?.inbound_number || inboundNumber || "";
setResultMsg(`${items.length}건 입고 등록 완료! (${inboundNo})`);
setTimeout(() => {
onClear();
@@ -268,10 +291,13 @@ export function InboundCart({
router.push("/pop/inbound");
}, 1500);
} else {
setResultMsg(`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`);
setResultMsg(
`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`,
);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "입고 등록에 실패했습니다.";
const msg =
err instanceof Error ? err.message : "입고 등록에 실패했습니다.";
setResultMsg(`오류: ${msg}`);
} finally {
setConfirming(false);
@@ -291,8 +317,18 @@ export function InboundCart({
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-blue-500 flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22"
/>
</svg>
</div>
<div>
@@ -306,8 +342,18 @@ export function InboundCart({
onClick={onClose}
className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500 hover:bg-gray-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
<svg
className="w-4 h-4"
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>
@@ -324,8 +370,18 @@ export function InboundCart({
}`}
>
{selectedItems.size === items.length && (
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" strokeWidth={3} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</button>
@@ -339,8 +395,18 @@ export function InboundCart({
<div className="flex-1 overflow-y-auto px-5 py-3">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<svg className="w-12 h-12 mb-3 opacity-30" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
<svg
className="w-12 h-12 mb-3 opacity-30"
fill="none"
stroke="currentColor"
strokeWidth={1}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22"
/>
</svg>
<p className="text-sm"> </p>
</div>
@@ -363,14 +429,26 @@ export function InboundCart({
}`}
>
{selectedItems.has(item.id) && (
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" strokeWidth={3} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{item.item_name}</p>
<p className="text-sm font-semibold text-gray-900 truncate">
{item.item_name}
</p>
<p className="text-[11px] text-gray-400 mt-0.5">
{item.item_code} | {item.purchase_no}
</p>
@@ -381,8 +459,18 @@ export function InboundCart({
onClick={() => onRemove(item.id)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-white bg-red-400 hover:bg-red-500 transition-colors shrink-0"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
@@ -402,16 +490,21 @@ export function InboundCart({
</span>
<span className="text-[10px] text-green-600 font-semibold">
{"\uD83D\uDCE6"} {item.packages.map(p =>
`${p.count}${p.unit.label} x ${p.qtyPerUnit.toLocaleString()} = ${(p.count * p.qtyPerUnit).toLocaleString()}EA`
).join(", ")}
{"\uD83D\uDCE6"}{" "}
{item.packages
.map(
(p) =>
`${p.count}${p.unit.label} x ${p.qtyPerUnit.toLocaleString()} = ${(p.count * p.qtyPerUnit).toLocaleString()}EA`,
)
.join(", ")}
</span>
</div>
</div>
)}
{/* Inspection row */}
{(item.inspection_type === "self" || item.inspection_type === "request") && (
{(item.inspection_type === "self" ||
item.inspection_type === "request") && (
<div className="ml-[30px] mb-2">
<button
onClick={() => openInspection(item)}
@@ -424,24 +517,40 @@ export function InboundCart({
}`}
>
<span className="text-[13px] font-semibold">
{item.inspection_type === "self" ? "검사" : "검사의뢰"}
{item.inspection_type === "self"
? "검사"
: "검사의뢰"}
</span>
<span className={`text-[11px] px-1.5 py-0.5 rounded ${
<span
className={`text-[11px] px-1.5 py-0.5 rounded ${
item.inspection_required
? "bg-red-100 text-red-600"
: "bg-blue-100 text-blue-600"
}`}>
}`}
>
{item.inspection_required ? "필수" : "선택"}
</span>
<span className={`ml-auto text-[12px] font-semibold ${
<span
className={`ml-auto text-[12px] font-semibold ${
item.inspectionResult?.completed
? "text-green-600"
: "text-gray-400"
}`}>
}`}
>
{item.inspectionResult?.completed ? "완료" : "대기"}
</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="M8.25 4.5l7.5 7.5-7.5 7.5" />
<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="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
@@ -450,15 +559,32 @@ export function InboundCart({
{/* Qty controls */}
<div className="flex items-center justify-between ml-[30px]">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">: {item.remain_qty.toLocaleString()}</span>
<span className="text-xs text-gray-400">
: {item.remain_qty.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => onUpdateQty(item.id, Math.max(1, item.inbound_qty - 1))}
onClick={() =>
onUpdateQty(
item.id,
Math.max(1, item.inbound_qty - 1),
)
}
className="w-8 h-8 rounded-lg 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-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 12h-15"
/>
</svg>
</button>
<input
@@ -466,17 +592,33 @@ export function InboundCart({
value={item.inbound_qty}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 0) onUpdateQty(item.id, Math.min(v, item.remain_qty));
if (!isNaN(v) && v >= 0)
onUpdateQty(item.id, Math.min(v, item.remain_qty));
}}
className="w-16 h-8 text-center text-sm font-semibold border border-gray-200 rounded-lg outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-100"
style={{ fontVariantNumeric: "tabular-nums" }}
/>
<button
onClick={() => onUpdateQty(item.id, Math.min(item.remain_qty, item.inbound_qty + 1))}
onClick={() =>
onUpdateQty(
item.id,
Math.min(item.remain_qty, item.inbound_qty + 1),
)
}
className="w-8 h-8 rounded-lg 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-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
</button>
</div>
@@ -492,16 +634,22 @@ export function InboundCart({
<div className="border-t border-gray-100 px-5 py-4">
{/* 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"
}`}>
<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>
)}
{/* Warehouse selection */}
<div className="mb-3">
<label className="text-xs font-semibold text-gray-500 mb-1 block"> </label>
<label className="text-xs font-semibold text-gray-500 mb-1 block">
</label>
<select
value={selectedWarehouse}
onChange={(e) => setSelectedWarehouse(e.target.value)}
@@ -522,11 +670,16 @@ export function InboundCart({
{/* Summary */}
<div className="flex items-center justify-between mb-3 text-sm">
<span className="text-gray-500">
<span className="font-bold text-gray-900">{items.length}</span>
{" "}
<span className="font-bold text-gray-900">{items.length}</span>
</span>
<div className="flex items-center gap-3">
<span className="text-gray-500">
: <span className="font-bold text-blue-600">{totalQty.toLocaleString()}</span>
:{" "}
<span className="font-bold text-blue-600">
{totalQty.toLocaleString()}
</span>
</span>
{totalAmount > 0 && (
<span className="text-gray-400 text-xs">
@@ -539,7 +692,9 @@ export function InboundCart({
{/* Buttons */}
<div className="flex gap-3">
<button
onClick={() => { onClear(); }}
onClick={() => {
onClear();
}}
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"
>
@@ -564,7 +719,10 @@ export function InboundCart({
{inspectionTarget && (
<InspectionModal
open={inspectionModalOpen}
onClose={() => { setInspectionModalOpen(false); setInspectionTarget(null); }}
onClose={() => {
setInspectionModalOpen(false);
setInspectionTarget(null);
}}
onComplete={handleInspectionComplete}
itemCode={inspectionTarget.item_code}
itemName={inspectionTarget.item_name}
@@ -1,11 +1,17 @@
"use client";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useRouter } from "next/navigation";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { apiClient } from "@/lib/api/client";
import { type CartItemWithId, useCartSync } from "../common/useCartSync";
import { InspectionModal, type InspectionResult } from "./InspectionModal";
import { NumberPadModal, type PackageEntry } from "./NumberPadModal";
import { useCartSync, type CartItemWithId } from "../common/useCartSync";
/* ------------------------------------------------------------------ */
/* Types */
@@ -47,15 +53,19 @@ interface CartItemParsed {
/* ------------------------------------------------------------------ */
function toCartItemParsed(item: CartItemWithId): CartItemParsed {
const data = item.row;
const inspType = data.inspection_type === "self" ? "self"
: data.inspection_type === "request" ? "request"
const inspType =
data.inspection_type === "self"
? "self"
: data.inspection_type === "request"
? "request"
: null;
return {
id: item.rowKey || String(data.id ?? ""),
rowKey: item.rowKey,
dbId: item.cartId || "",
source_table: item.sourceTable || String(data.source_table ?? "purchase_detail"),
source_table:
item.sourceTable || String(data.source_table ?? "purchase_detail"),
source_id: item.rowKey || String(data.id ?? ""),
purchase_no: String(data.purchase_no ?? ""),
item_code: String(data.item_code ?? ""),
@@ -157,7 +167,7 @@ export function InboundCartPage() {
/* Inbound date */
const [inboundDate, setInboundDate] = useState<string>(
new Date().toISOString().slice(0, 10)
new Date().toISOString().slice(0, 10),
);
/* Confirm state */
@@ -166,7 +176,8 @@ export function InboundCartPage() {
/* Inspection modal */
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionTarget, setInspectionTarget] = useState<CartItemParsed | null>(null);
const [inspectionTarget, setInspectionTarget] =
useState<CartItemParsed | null>(null);
/* Numpad modal (for qty edit) */
const [numpadOpen, setNumpadOpen] = useState(false);
@@ -237,7 +248,7 @@ export function InboundCartPage() {
undefined,
// PackageEntry 타입이 registry vs NumberPadModal에서 다르므로 any 캐스팅
// eslint-disable-next-line @typescript-eslint/no-explicit-any
packages.length > 0 ? packages as any : undefined,
packages.length > 0 ? (packages as any) : undefined,
);
setNumpadTarget(null);
// Auto-save effect below will persist change to DB
@@ -290,7 +301,9 @@ export function InboundCartPage() {
setInspectionTarget(null);
// 즉시 DB 저장 (자동저장 디바운스를 기다리지 않음)
setTimeout(() => {
cart.saveToDb().catch((err) => console.error("[검사 결과 저장 실패]", err));
cart
.saveToDb()
.catch((err) => console.error("[검사 결과 저장 실패]", err));
}, 100);
};
@@ -328,7 +341,7 @@ export function InboundCartPage() {
(item) =>
item.inspection_required &&
item.inspection_type === "self" &&
!getInspectionResult(item.rowKey)?.completed
!getInspectionResult(item.rowKey)?.completed,
);
/* ------------------------------------------------------------------ */
@@ -353,9 +366,14 @@ export function InboundCartPage() {
// POP 화면설정에서 선택한 채번규칙 사용 (없으면 기본)
let finalNumber = "";
try {
const settingsRes: any = await apiClient.get("/screen-management/screens/6527/layout-pop").catch(() => null);
const ruleId = settingsRes?.data?.data?.settings?.popConfig?.inbound?.numberingRuleId;
const url = ruleId && ruleId !== "__none__"
const settingsRes: any = await apiClient
.get("/screen-management/screens/6527/layout-pop")
.catch(() => null);
const ruleId =
settingsRes?.data?.data?.settings?.popConfig?.inbound
?.numberingRuleId;
const url =
ruleId && ruleId !== "__none__"
? `/receiving/generate-number?ruleId=${encodeURIComponent(ruleId)}`
: "/receiving/generate-number";
const numRes = await apiClient.get(url);
@@ -385,7 +403,7 @@ export function InboundCartPage() {
inbound_qty: String(item.inbound_qty),
unit_price: String(item.unit_price || 0),
total_amount: String(
(item.inbound_qty || 0) * (item.unit_price || 0)
(item.inbound_qty || 0) * (item.unit_price || 0),
),
reference_number: item.purchase_no,
supplier_code: item.supplier_code,
@@ -412,8 +430,10 @@ export function InboundCartPage() {
(res.data?.data?.items as Array<Record<string, unknown>>) ??
[];
const inboundHeaderNo: string =
(res.data?.data?.header as { inbound_number?: string } | undefined)?.inbound_number ||
finalNumber || "";
(res.data?.data?.header as { inbound_number?: string } | undefined)
?.inbound_number ||
finalNumber ||
"";
const inspectionPromises = selectedItemsList
.map((item, idx) => {
const inspResult = getInspectionResult(item.rowKey);
@@ -458,7 +478,11 @@ export function InboundCartPage() {
})
.catch((err: unknown) => {
const e = err as { message?: string };
console.error("[inspection_result 저장 실패]", item.item_code, e?.message);
console.error(
"[inspection_result 저장 실패]",
item.item_code,
e?.message,
);
});
})
.filter(Boolean);
@@ -471,7 +495,11 @@ export function InboundCartPage() {
const { dataApi } = await import("@/lib/api/data");
const confirmPromises = confirmedItems
.filter((item) => item.dbId)
.map((item) => dataApi.updateRecord("cart_items", item.dbId, { status: "confirmed" }).catch(() => {}));
.map((item) =>
dataApi
.updateRecord("cart_items", item.dbId, { status: "confirmed" })
.catch(() => {}),
);
await Promise.all(confirmPromises);
// Also clean up local state via useCartSync
@@ -488,13 +516,15 @@ export function InboundCartPage() {
setConfirmResult({
inboundNumber: inboundNo,
items: confirmedItems,
warehouse: warehouses.find(w => w.warehouse_code === selectedWarehouse)?.warehouse_name || selectedWarehouse,
warehouse:
warehouses.find((w) => w.warehouse_code === selectedWarehouse)
?.warehouse_name || selectedWarehouse,
date: inboundDate,
});
setResultMsg(null);
} else {
setResultMsg(
`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`
`오류: ${res.data?.message || "입고 등록에 실패했습니다."}`,
);
}
} catch (err: unknown) {
@@ -596,9 +626,7 @@ export function InboundCartPage() {
{supplierName}
</span>
)}
<span className="text-[11px] text-gray-400">
{inboundDate}
</span>
<span className="text-[11px] text-gray-400">{inboundDate}</span>
{selectedWarehouseName && (
<span className="text-[11px] text-gray-400">
| {selectedWarehouseName}
@@ -633,7 +661,13 @@ export function InboundCartPage() {
onClick={() => setWarehousePickerOpen(true)}
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-left outline-none hover:border-blue-300 transition-all bg-white flex items-center justify-between"
>
<span className={selectedWarehouse ? "text-gray-900 font-medium" : "text-gray-400"}>
<span
className={
selectedWarehouse
? "text-gray-900 font-medium"
: "text-gray-400"
}
>
{selectedWarehouseName || "창고 선택"}
</span>
<svg
@@ -697,8 +731,7 @@ export function InboundCartPage() {
)}
</button>
<span className="text-sm font-semibold text-gray-700">
{" "}
<span className="text-blue-600">{items.length}</span>
<span className="text-blue-600">{items.length}</span>
</span>
</div>
@@ -812,8 +845,12 @@ export function InboundCartPage() {
</svg>
)}
</button>
<span className="text-[11px] text-gray-400 font-medium shrink-0">{item.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{item.item_name}</span>
<span className="text-[11px] text-gray-400 font-medium shrink-0">
{item.item_code}
</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">
{item.item_name}
</span>
{item.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
@@ -831,29 +868,51 @@ export function InboundCartPage() {
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{item.image ? (
<img src={item.image} alt={item.item_name} className="w-full h-full object-cover rounded-lg" />
<img
src={item.image}
alt={item.item_name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
<span className="text-2xl text-gray-300">
{"\uD83D\uDCE6"}
</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{item.order_date || "-"}</span>
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{item.order_date || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{item.purchase_no || "-"}</span>
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700 truncate">
{item.purchase_no || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{item.order_qty.toLocaleString()}</span>
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{item.order_qty.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">{item.remain_qty.toLocaleString()}</span>
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-bold text-red-500">
{item.remain_qty.toLocaleString()}
</span>
</div>
</div>
@@ -878,8 +937,18 @@ export function InboundCartPage() {
onClick={() => handleRemove(item.rowKey)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
@@ -894,13 +963,23 @@ export function InboundCartPage() {
</span>
<span className="text-[11px] font-semibold text-green-600">
{item.packages.reduce((s, p) => s + p.count * p.qtyPerUnit, 0).toLocaleString()} EA
{item.packages
.reduce((s, p) => s + p.count * p.qtyPerUnit, 0)
.toLocaleString()}{" "}
EA
</span>
</div>
{item.packages.map((pkg, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<div
key={idx}
className="flex items-center gap-1.5 text-[11px] text-gray-600"
>
<span>{pkg.unit.icon}</span>
<span>{pkg.count}{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA = {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA</span>
<span>
{pkg.count}
{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA
= {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA
</span>
</div>
))}
</div>
@@ -960,8 +1039,7 @@ export function InboundCartPage() {
</button>
{/* Pass button for non-required */}
{!item.inspection_required &&
!inspResult?.completed && (
{!item.inspection_required && !inspResult?.completed && (
<button
onClick={() => handlePassInspection(item.rowKey)}
className="px-3 py-2.5 rounded-lg border border-gray-200 bg-gray-50 text-xs font-semibold text-gray-500 hover:bg-gray-100 active:scale-95 transition-all whitespace-nowrap"
@@ -1148,32 +1226,65 @@ export function InboundCartPage() {
<div className="absolute inset-0 bg-black/50" />
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden z-10">
{/* 헤더 */}
<div className="px-6 py-5 text-center" style={{ background: "linear-gradient(to bottom, #60a5fa, #2563eb)" }}>
<div
className="px-6 py-5 text-center"
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
}}
>
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-3">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</div>
<h3 className="text-xl font-bold text-white"> </h3>
<p className="text-white/80 text-lg font-mono mt-1">{confirmResult.inboundNumber}</p>
<p className="text-white/80 text-lg font-mono mt-1">
{confirmResult.inboundNumber}
</p>
</div>
{/* 처리 내역 */}
<div className="px-6 py-4">
<div className="flex items-center justify-between text-sm text-gray-500 mb-3">
<span>: <span className="font-semibold text-gray-900">{confirmResult.warehouse}</span></span>
<span>
:{" "}
<span className="font-semibold text-gray-900">
{confirmResult.warehouse}
</span>
</span>
<span>{confirmResult.date}</span>
</div>
<div className="text-xs font-semibold text-gray-400 mb-2"> ({confirmResult.items.length})</div>
<div className="text-xs font-semibold text-gray-400 mb-2">
({confirmResult.items.length})
</div>
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-2">
{confirmResult.items.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 bg-green-50 rounded-xl border border-green-100">
<div
key={item.id}
className="flex items-center justify-between p-3 bg-green-50 rounded-xl border border-green-100"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{item.item_name}</p>
<p className="text-[11px] text-gray-400">{item.item_code}</p>
<p className="text-sm font-semibold text-gray-900 truncate">
{item.item_name}
</p>
<p className="text-[11px] text-gray-400">
{item.item_code}
</p>
</div>
<span className="text-sm font-bold text-green-600 ml-3">{item.inbound_qty?.toLocaleString()} EA</span>
<span className="text-sm font-bold text-green-600 ml-3">
{item.inbound_qty?.toLocaleString()} EA
</span>
</div>
))}
</div>
@@ -1187,7 +1298,10 @@ export function InboundCartPage() {
router.push("/pop/inbound");
}}
className="w-full h-12 rounded-xl text-white font-bold text-base active:scale-[0.98] transition-all"
style={{ background: "linear-gradient(to bottom, #60a5fa, #2563eb)", boxShadow: "0 4px 12px rgba(59,130,246,.3)" }}
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
boxShadow: "0 4px 12px rgba(59,130,246,.3)",
}}
>
</button>
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
@@ -112,7 +112,12 @@ export function InspectionModal({
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) => {
const openNumpad = (
title: string,
currentValue: string | number,
onConfirm: (v: string) => void,
max?: number,
) => {
setNumpadTitle(title);
setNumpadValue(String(currentValue || ""));
setNumpadMax(max);
@@ -140,7 +145,7 @@ export function InspectionModal({
is_required: String(r.is_required ?? "Y"),
measured_value: "",
result: null,
}))
})),
);
} else {
setInspItems(DUMMY_INSPECTION_ITEMS.map((i) => ({ ...i })));
@@ -169,13 +174,25 @@ export function InspectionModal({
}, [open, initialResult, fetchInspectionItems, totalQty]);
/* Update item */
const updateItem = (id: string, field: "measured_value" | "result", value: string) => {
const updateItem = (
id: string,
field: "measured_value" | "result",
value: string,
) => {
setInspItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, [field]: field === "result" ? (item.result === value ? null : value) : value }
: item
)
? {
...item,
[field]:
field === "result"
? item.result === value
? null
: value
: value,
}
: item,
),
);
};
@@ -207,7 +224,9 @@ export function InspectionModal({
let inspectionNumber = initialResult?.inspectionNumber;
if (!inspectionNumber) {
try {
const res = await apiClient.post("/pop/inspection-result/allocate-number");
const res = await apiClient.post(
"/pop/inspection-result/allocate-number",
);
inspectionNumber = res.data?.data?.inspectionNumber;
} catch (err) {
console.error("[검사번호 채번 실패]", err);
@@ -253,8 +272,18 @@ export function InspectionModal({
onClick={onClose}
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" />
<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>
@@ -262,8 +291,11 @@ export function InspectionModal({
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto px-5 py-4 bg-gray-50">
{/* Item summary */}
<div className="flex flex-wrap items-center gap-2 px-3.5 py-2.5 mb-4 rounded-lg border border-sky-200"
style={{ background: "linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)" }}
<div
className="flex flex-wrap items-center gap-2 px-3.5 py-2.5 mb-4 rounded-lg border border-sky-200"
style={{
background: "linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)",
}}
>
<span className="text-xs font-semibold text-sky-700 bg-white px-2 py-0.5 rounded shrink-0">
{itemCode}
@@ -287,9 +319,24 @@ export function InspectionModal({
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-gray-400">
<svg className="animate-spin w-5 h-5 mr-2 text-blue-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<svg
className="animate-spin w-5 h-5 mr-2 text-blue-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
...
</div>
@@ -303,7 +350,9 @@ export function InspectionModal({
<div
key={item.id}
className={`bg-gray-50 border rounded-lg p-3 ${
item.is_required === "Y" ? "border-l-[3px] border-l-red-400 border-gray-200" : "border-gray-200"
item.is_required === "Y"
? "border-l-[3px] border-l-red-400 border-gray-200"
: "border-gray-200"
}`}
>
{/* Item header */}
@@ -323,19 +372,25 @@ export function InspectionModal({
{item.inspection_standard && (
<>
<span className="text-gray-400"></span>
<span className="text-gray-700">{item.inspection_standard}</span>
<span className="text-gray-700">
{item.inspection_standard}
</span>
</>
)}
{item.inspection_method && (
<>
<span className="text-gray-400"></span>
<span className="text-gray-700">{item.inspection_method}</span>
<span className="text-gray-700">
{item.inspection_method}
</span>
</>
)}
{item.pass_criteria && (
<>
<span className="text-gray-400"></span>
<span className="text-gray-700">{item.pass_criteria}</span>
<span className="text-gray-700">
{item.pass_criteria}
</span>
</>
)}
</div>
@@ -344,11 +399,13 @@ export function InspectionModal({
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openNumpad(
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v)
)}
(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"
@@ -384,9 +441,23 @@ export function InspectionModal({
className="w-9 h-9 rounded-md border border-gray-200 text-gray-400 flex items-center justify-center hover:bg-gray-50 transition-colors"
title="사진 촬영 (준비 중)"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
/>
</svg>
</button>
</div>
@@ -398,16 +469,27 @@ export function InspectionModal({
{/* Final judgment section */}
<div className="bg-white rounded-xl border border-gray-200 p-3.5 mb-4">
<h4 className="text-sm font-semibold text-gray-700 mb-3"> </h4>
<h4 className="text-sm font-semibold text-gray-700 mb-3">
</h4>
{/* Good / Bad qty */}
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-green-600"> </label>
<label className="text-[11px] font-semibold text-green-600">
</label>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => openNumpad("양품 수량", goodQty, (v) => handleGoodQtyChange(parseInt(v, 10) || 0), totalQty)}
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()}
@@ -416,11 +498,20 @@ export function InspectionModal({
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-red-600"> </label>
<label className="text-[11px] font-semibold text-red-600">
</label>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => openNumpad("불량 수량", badQty, (v) => handleBadQtyChange(parseInt(v, 10) || 0), totalQty)}
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()}
@@ -482,9 +573,16 @@ export function InspectionModal({
}`}
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
boxShadow: !canComplete || allocating ? "none" : "0 4px 12px rgba(59,130,246,.3)",
boxShadow:
!canComplete || allocating
? "none"
: "0 4px 12px rgba(59,130,246,.3)",
}}
title={!canComplete ? "필수 검사 항목을 모두 합격(✓)으로 체크해주세요" : ""}
title={
!canComplete
? "필수 검사 항목을 모두 합격(✓)으로 체크해주세요"
: ""
}
>
{allocating ? "처리 중..." : "검사 완료"}
</button>
@@ -493,7 +591,10 @@ export function InspectionModal({
{/* ===== NumPad ===== */}
{numpadOpen && (
<div className="absolute inset-0 z-20 flex items-end justify-center" onClick={() => setNumpadOpen(false)}>
<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"
@@ -503,21 +604,41 @@ export function InspectionModal({
<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" />
<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" }}>
<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>
<p className="text-[11px] text-gray-400 text-right mb-2">
{numpadMax.toLocaleString()}
</p>
)}
</div>
<div className="px-5 pb-5">
@@ -525,26 +646,38 @@ export function InspectionModal({
{["7", "8", "9", "4", "5", "6", "1", "2", "3"].map((k) => (
<button
key={k}
onClick={() => setNumpadValue((v) => (v === "0" || v === "" ? k : v + 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") + ".")}
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"))}
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))}
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"
>
@@ -579,7 +712,9 @@ export function InspectionModal({
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)" }}
style={{
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
}}
>
</button>
+22 -6
View File
@@ -1,9 +1,25 @@
export { PopShell } from "./PopShell";
export {
InboundCart,
InboundTypeSelect,
PurchaseInbound,
SupplierModal,
} from "./inbound";
export { InOutHistory, InventoryHome } from "./inventory";
export { KpiCarousel } from "./KpiCarousel";
export { MenuIcons } from "./MenuIcons";
export {
CustomerModal,
OutboundCartPage,
OutboundTypeSelect,
SalesOutbound,
} from "./outbound";
export { PopShell } from "./PopShell";
export {
AcceptProcessModal,
DefectTypeModal,
ProcessTimer,
ProcessWork,
WorkOrderList,
} from "./production";
export { InspectionList, QualityHome } from "./quality";
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";
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import React, { useEffect, useRef, useState } from "react";
/* ------------------------------------------------------------------ */
/* Types */
@@ -46,7 +46,20 @@ function isBetween(date: string, from: string, to: string): boolean {
}
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"];
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
const MONTH_NAMES = [
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월",
];
/* ------------------------------------------------------------------ */
/* Component */
@@ -64,7 +77,10 @@ export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
@@ -104,25 +120,42 @@ export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
};
const prevMonth = () => {
if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11); }
else setViewMonth(viewMonth - 1);
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);
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 },
{
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
const displayText =
from && to
? isSame(from, to)
? fmtDisplay(from)
: `${fmtDisplay(from)} ~ ${fmtDisplay(to)}`
@@ -142,14 +175,30 @@ export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
<div ref={containerRef} className="relative">
{/* Trigger Button */}
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<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" />
<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>
@@ -159,7 +208,9 @@ export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
<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" ? "시작일을 선택하세요" : "종료일을 선택하세요 (같은 날 = 당일)"}
{selecting === "from"
? "시작일을 선택하세요"
: "종료일을 선택하세요 (같은 날 = 당일)"}
</p>
{/* Quick Presets */}
@@ -167,7 +218,10 @@ export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
{presets.map((p) => (
<button
key={p.label}
onClick={() => { onChange(p.from, p.to); setOpen(false); }}
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}
@@ -177,19 +231,54 @@ export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
{/* 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
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>
<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"}`}>
<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>
))}
@@ -198,17 +287,24 @@ export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
{/* Day Grid */}
<div className="grid grid-cols-7 gap-0">
{cells.map((dateStr, idx) => {
if (!dateStr) return <div key={`empty-${idx}`} className="h-10" />;
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 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";
let textClass =
dayOfWeek === 0
? "text-red-500"
: dayOfWeek === 6
? "text-blue-500"
: "text-gray-700";
if (isStart || isEnd) {
bgClass = "bg-cyan-600 text-white";
@@ -239,7 +335,9 @@ export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
{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)}`}
{isSame(tempFrom, tempTo)
? fmtDisplay(tempFrom)
: `${fmtDisplay(tempFrom)} ~ ${fmtDisplay(tempTo)}`}
</span>
</div>
)}
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "./DateRangePicker";
@@ -50,7 +50,10 @@ type TabKey = "all" | "inbound" | "outbound" | "transfer";
/* Helpers */
/* ------------------------------------------------------------------ */
function getStatusStyle(status: string | null): { color: string; label: string } {
function getStatusStyle(status: string | null): {
color: string;
label: string;
} {
switch (status) {
case "완료":
case "입고완료":
@@ -75,25 +78,44 @@ 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 [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 }[]>([]);
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 [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) => {
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(() => {});
setWarehouses(
data.map((w: any) => ({
code: w.warehouse_code || "",
name: w.warehouse_name || "",
})),
);
})
.catch(() => {});
}, []);
/* Fetch data */
@@ -138,9 +160,16 @@ export function InOutHistory() {
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" }) : "--:--",
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") : "-",
fullDate: r.created_date
? new Date(r.created_date).toLocaleString("ko-KR")
: "-",
};
}),
...outRows.map((r: any, idx: number) => {
@@ -168,9 +197,16 @@ export function InOutHistory() {
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" }) : "--:--",
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") : "-",
fullDate: r.created_date
? new Date(r.created_date).toLocaleString("ko-KR")
: "-",
};
}),
].sort((a, b) => b.time.localeCompare(a.time));
@@ -201,13 +237,22 @@ export function InOutHistory() {
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 (
!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 }[] = [
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 },
@@ -222,13 +267,27 @@ export function InOutHistory() {
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
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>
<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>
@@ -239,10 +298,15 @@ export function InOutHistory() {
<DateRangePicker
from={dateFrom}
to={dateTo}
onChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
onChange={(f, t) => {
setDateFrom(f);
setDateTo(t);
}}
/>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<input
type="text"
value={keyword}
@@ -252,7 +316,9 @@ export function InOutHistory() {
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<select
value={warehouse}
onChange={(e) => setWarehouse(e.target.value)}
@@ -260,7 +326,9 @@ export function InOutHistory() {
>
<option value="전체"></option>
{warehouses.map((w) => (
<option key={w.code} value={w.name}>{w.name}</option>
<option key={w.code} value={w.name}>
{w.name}
</option>
))}
</select>
</div>
@@ -274,7 +342,12 @@ export function InOutHistory() {
</button>
<button
onClick={() => { setDateFrom(new Date().toISOString().slice(0, 10)); setDateTo(new Date().toISOString().slice(0, 10)); setKeyword(""); setWarehouse("전체"); }}
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"
>
@@ -286,10 +359,30 @@ export function InOutHistory() {
{/* 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" />
<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>
@@ -321,14 +414,19 @@ export function InOutHistory() {
{/* 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 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
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">
@@ -342,15 +440,31 @@ export function InOutHistory() {
</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
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-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
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
@@ -358,7 +472,8 @@ export function InOutHistory() {
item.direction === "입고" ? "" : ""
}`}
style={{
background: item.direction === "입고"
background:
item.direction === "입고"
? "linear-gradient(135deg,#3b82f6,#1d4ed8)"
: "linear-gradient(135deg,#22c55e,#15803d)",
}}
@@ -373,7 +488,9 @@ export function InOutHistory() {
{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}`}>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}
>
{item.statusLabel}
</span>
</div>
@@ -384,10 +501,18 @@ export function InOutHistory() {
{/* 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
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>
<p className="text-[10px] text-gray-400 mt-0.5">{item.time}</p>
</div>
</div>
</div>
@@ -397,7 +522,10 @@ export function InOutHistory() {
{/* Detail Bottom Sheet */}
{selectedItem && (
<div className="fixed inset-0 z-50 flex items-end justify-center" onClick={() => setSelectedItem(null)}>
<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 */}
@@ -413,14 +541,25 @@ export function InOutHistory() {
{/* 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}
{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
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>
@@ -437,8 +576,12 @@ export function InOutHistory() {
<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}`}>
<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>
@@ -448,23 +591,40 @@ export function InOutHistory() {
{/* Row 3: 품목 */}
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1"></p>
<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}
{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 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 || "-"} />
<DetailField
label="LOT번호"
value={selectedItem.lotNumber || "-"}
/>
</div>
<div className="border-t border-gray-100" />
@@ -472,26 +632,50 @@ export function InOutHistory() {
{/* 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>}
<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.writer || "-"}
/>
<DetailField label="비고" value={selectedItem.memo || "-"} />
</div>
{/* Row 7: 참조번호 + 금액 (있을 때만) */}
{(selectedItem.referenceNumber || selectedItem.totalAmount > 0) && (
{(selectedItem.referenceNumber ||
selectedItem.totalAmount > 0) && (
<div className="grid grid-cols-2 gap-4">
{selectedItem.referenceNumber ? <DetailField label="참조번호" value={selectedItem.referenceNumber} /> : <div />}
{selectedItem.referenceNumber ? (
<DetailField
label="참조번호"
value={selectedItem.referenceNumber}
/>
) : (
<div />
)}
{selectedItem.totalAmount > 0 ? (
<DetailField label="금액" value={`${selectedItem.totalAmount.toLocaleString()}`} />
) : <div />}
<DetailField
label="금액"
value={`${selectedItem.totalAmount.toLocaleString()}`}
/>
) : (
<div />
)}
</div>
)}
</div>
@@ -535,7 +719,17 @@ function DetailField({ label, value }: { label: string; value: string }) {
);
}
function KpiCell({ icon, value, label, color }: { icon: string; value: string; label: string; color: string }) {
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>
@@ -545,7 +739,9 @@ function KpiCell({ icon, value, label, color }: { icon: string; value: string; l
>
{value}
</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">{label}</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">
{label}
</span>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
@@ -30,7 +30,10 @@ interface KpiData {
/* Helpers */
/* ------------------------------------------------------------------ */
function getStatusStyle(status: string | null): { color: string; label: string } {
function getStatusStyle(status: string | null): {
color: string;
label: string;
} {
switch (status) {
case "완료":
case "입고완료":
@@ -56,8 +59,18 @@ const MENU_ITEMS = [
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
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",
@@ -68,8 +81,18 @@ const MENU_ITEMS = [
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
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: "#",
@@ -83,7 +106,11 @@ const MENU_ITEMS = [
export function InventoryHome() {
const router = useRouter();
const [kpi, setKpi] = useState<KpiData>({ todayInbound: 0, todayOutbound: 0, todayTotal: 0 });
const [kpi, setKpi] = useState<KpiData>({
todayInbound: 0,
todayOutbound: 0,
todayTotal: 0,
});
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -94,8 +121,12 @@ export function InventoryHome() {
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 } }),
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 ?? [];
@@ -112,7 +143,12 @@ export function InventoryHome() {
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" }) : "--:--",
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 || "-",
@@ -126,7 +162,12 @@ export function InventoryHome() {
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" }) : "--:--",
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 || "-",
@@ -167,22 +208,48 @@ export function InventoryHome() {
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
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>
<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" />
<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>
@@ -190,7 +257,9 @@ export function InventoryHome() {
<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>
<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) => (
@@ -202,7 +271,10 @@ export function InventoryHome() {
>
<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}` }}
style={{
background: item.gradient,
boxShadow: `0 4px 12px ${item.shadowColor}`,
}}
>
{item.icon}
</div>
@@ -218,7 +290,9 @@ export function InventoryHome() {
<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>
<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">
@@ -235,20 +309,34 @@ export function InventoryHome() {
))}
</div>
) : recentItems.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400"> </div>
<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" }}>
<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"}`}>
<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}`}>
<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>
@@ -270,7 +358,15 @@ export function InventoryHome() {
/* Sub-components */
/* ------------------------------------------------------------------ */
function KpiCell({ value, label, color }: { value: string; label: string; color: string }) {
function KpiCell({
value,
label,
color,
}: {
value: string;
label: string;
color: string;
}) {
return (
<div className="flex flex-col items-center py-2">
<span
@@ -279,7 +375,9 @@ function KpiCell({ value, label, color }: { value: string; label: string; color:
>
{value}
</span>
<span className="text-[11px] font-medium text-gray-400 mt-1">{label}</span>
<span className="text-[11px] font-medium text-gray-400 mt-1">
{label}
</span>
</div>
);
}
@@ -1,2 +1,2 @@
export { InventoryHome } from "./InventoryHome";
export { InOutHistory } from "./InOutHistory";
export { InventoryHome } from "./InventoryHome";
@@ -1,11 +1,20 @@
"use client";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useRouter } from "next/navigation";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { apiClient } from "@/lib/api/client";
import { InspectionModal, type InspectionResult } from "../inbound/InspectionModal";
import { type CartItemWithId, useCartSync } from "../common/useCartSync";
import {
InspectionModal,
type InspectionResult,
} from "../inbound/InspectionModal";
import { NumberPadModal, type PackageEntry } from "../inbound/NumberPadModal";
import { useCartSync, type CartItemWithId } from "../common/useCartSync";
/* ------------------------------------------------------------------ */
/* Types */
@@ -47,16 +56,22 @@ interface CartItemParsed {
/* ------------------------------------------------------------------ */
function toCartItemParsed(item: CartItemWithId): CartItemParsed {
const data = item.row;
const inspType = data.inspection_type === "self" ? "self"
: data.inspection_type === "request" ? "request"
const inspType =
data.inspection_type === "self"
? "self"
: data.inspection_type === "request"
? "request"
: null;
return {
id: item.rowKey || String(data.id ?? ""),
rowKey: item.rowKey,
dbId: item.cartId || "",
source_table: item.sourceTable || String(data.source_table ?? "shipment_instruction_detail"),
source_id: item.rowKey || String(data.source_id ?? data.detail_id ?? data.id ?? ""),
source_table:
item.sourceTable ||
String(data.source_table ?? "shipment_instruction_detail"),
source_id:
item.rowKey || String(data.source_id ?? data.detail_id ?? data.id ?? ""),
instruction_no: String(data.instruction_no ?? ""),
item_code: String(data.item_code ?? ""),
item_name: String(data.item_name ?? ""),
@@ -68,7 +83,9 @@ function toCartItemParsed(item: CartItemWithId): CartItemParsed {
unit_price: Number(data.unit_price ?? 0),
customer_code: String(data.customer_code ?? ""),
customer_name: String(data.customer_name ?? ""),
instruction_date: data.instruction_date ? String(data.instruction_date) : undefined,
instruction_date: data.instruction_date
? String(data.instruction_date)
: undefined,
inspection_type: inspType,
inspection_required: inspType === "self",
packages: item.packageEntries as unknown as PackageEntry[] | undefined,
@@ -125,7 +142,7 @@ export function OutboundCartPage() {
/* Outbound date */
const [outboundDate, setOutboundDate] = useState<string>(
new Date().toISOString().slice(0, 10)
new Date().toISOString().slice(0, 10),
);
/* Confirm state */
@@ -134,7 +151,8 @@ export function OutboundCartPage() {
/* Inspection modal */
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionTarget, setInspectionTarget] = useState<CartItemParsed | null>(null);
const [inspectionTarget, setInspectionTarget] =
useState<CartItemParsed | null>(null);
/* Numpad modal (for qty edit) */
const [numpadOpen, setNumpadOpen] = useState(false);
@@ -204,7 +222,7 @@ export function OutboundCartPage() {
finalQty,
undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
packages.length > 0 ? packages as any : undefined,
packages.length > 0 ? (packages as any) : undefined,
);
setNumpadTarget(null);
};
@@ -281,7 +299,7 @@ export function OutboundCartPage() {
(item) =>
item.inspection_required &&
item.inspection_type === "self" &&
!getInspectionResult(item.rowKey)?.completed
!getInspectionResult(item.rowKey)?.completed,
);
/* ------------------------------------------------------------------ */
@@ -309,9 +327,14 @@ export function OutboundCartPage() {
// 출고 장바구니 전용 screen_id 7010
let finalNumber = "";
try {
const settingsRes: any = await apiClient.get("/screen-management/screens/7010/layout-pop").catch(() => null);
const ruleId = settingsRes?.data?.data?.settings?.popConfig?.outbound?.numberingRuleId;
const url = ruleId && ruleId !== "__none__"
const settingsRes: any = await apiClient
.get("/screen-management/screens/7010/layout-pop")
.catch(() => null);
const ruleId =
settingsRes?.data?.data?.settings?.popConfig?.outbound
?.numberingRuleId;
const url =
ruleId && ruleId !== "__none__"
? `/outbound/generate-number?ruleId=${encodeURIComponent(ruleId)}`
: "/outbound/generate-number";
const numRes = await apiClient.get(url);
@@ -356,7 +379,11 @@ export function OutboundCartPage() {
const { dataApi } = await import("@/lib/api/data");
const confirmPromises = confirmedItems
.filter((item) => item.dbId)
.map((item) => dataApi.updateRecord("cart_items", item.dbId, { status: "confirmed" }).catch(() => {}));
.map((item) =>
dataApi
.updateRecord("cart_items", item.dbId, { status: "confirmed" })
.catch(() => {}),
);
await Promise.all(confirmPromises);
// Also clean up local state via useCartSync
@@ -371,18 +398,23 @@ export function OutboundCartPage() {
setConfirmResult({
outboundNumber: outNo,
items: confirmedItems,
warehouse: warehouses.find(w => w.warehouse_code === selectedWarehouse)?.warehouse_name || selectedWarehouse,
warehouse:
warehouses.find((w) => w.warehouse_code === selectedWarehouse)
?.warehouse_name || selectedWarehouse,
date: outboundDate,
});
setResultMsg(null);
} else {
setResultMsg(
`오류: ${res.data?.message || "출고 등록에 실패했습니다."}`
`오류: ${res.data?.message || "출고 등록에 실패했습니다."}`,
);
}
} catch (err: unknown) {
// axios 에러 우선: 백엔드 message가 있으면 그것을 표시 (재고 부족 등)
const e = err as { response?: { data?: { message?: string } }; message?: string };
const e = err as {
response?: { data?: { message?: string } };
message?: string;
};
const backendMsg = e?.response?.data?.message;
const msg = backendMsg || e?.message || "출고 등록에 실패했습니다.";
setResultMsg(`오류: ${msg}`);
@@ -481,9 +513,7 @@ export function OutboundCartPage() {
{customerName}
</span>
)}
<span className="text-[11px] text-gray-400">
{outboundDate}
</span>
<span className="text-[11px] text-gray-400">{outboundDate}</span>
{selectedWarehouseName && (
<span className="text-[11px] text-gray-400">
| {selectedWarehouseName}
@@ -518,7 +548,13 @@ export function OutboundCartPage() {
onClick={() => setWarehousePickerOpen(true)}
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-left outline-none hover:border-green-300 transition-all bg-white flex items-center justify-between"
>
<span className={selectedWarehouse ? "text-gray-900 font-medium" : "text-gray-400"}>
<span
className={
selectedWarehouse
? "text-gray-900 font-medium"
: "text-gray-400"
}
>
{selectedWarehouseName || "창고 선택"}
</span>
<svg
@@ -582,8 +618,7 @@ export function OutboundCartPage() {
)}
</button>
<span className="text-sm font-semibold text-gray-700">
{" "}
<span className="text-green-600">{items.length}</span>
<span className="text-green-600">{items.length}</span>
</span>
</div>
@@ -697,8 +732,12 @@ export function OutboundCartPage() {
</svg>
)}
</button>
<span className="text-[11px] text-gray-400 font-medium shrink-0">{item.item_code}</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">{item.item_name}</span>
<span className="text-[11px] text-gray-400 font-medium shrink-0">
{item.item_code}
</span>
<span className="text-[13px] font-semibold text-gray-900 flex-1 truncate">
{item.item_name}
</span>
{item.inspection_type === "self" && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 border border-blue-200 shrink-0 whitespace-nowrap">
@@ -716,29 +755,51 @@ export function OutboundCartPage() {
{/* Product image */}
<div className="w-[56px] h-[56px] min-w-[56px] bg-gray-50 border border-gray-200 rounded-lg flex items-center justify-center shrink-0 overflow-hidden">
{item.image ? (
<img src={item.image} alt={item.item_name} className="w-full h-full object-cover rounded-lg" />
<img
src={item.image}
alt={item.item_name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<span className="text-2xl text-gray-300">{"\uD83D\uDCE6"}</span>
<span className="text-2xl text-gray-300">
{"\uD83D\uDCE6"}
</span>
)}
</div>
{/* Info columns */}
<div className="flex-1 min-w-0 flex flex-col gap-[3px]">
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{item.instruction_date || "-"}</span>
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{item.instruction_date || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700 truncate">{item.instruction_no || "-"}</span>
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700 truncate">
{item.instruction_no || "-"}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-medium text-gray-700">{item.plan_qty.toLocaleString()}</span>
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-medium text-gray-700">
{item.plan_qty.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<span className="text-gray-400 min-w-[45px] shrink-0"></span>
<span className="font-bold text-red-500">{item.remain_qty.toLocaleString()}</span>
<span className="text-gray-400 min-w-[45px] shrink-0">
</span>
<span className="font-bold text-red-500">
{item.remain_qty.toLocaleString()}
</span>
</div>
</div>
@@ -763,8 +824,18 @@ export function OutboundCartPage() {
onClick={() => handleRemove(item.rowKey)}
className="flex items-center justify-center gap-1 px-2.5 py-2 rounded-md bg-red-500 text-white text-xs font-semibold hover:bg-red-600 active:scale-95 transition-all"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
@@ -779,13 +850,23 @@ export function OutboundCartPage() {
</span>
<span className="text-[11px] font-semibold text-green-600">
{item.packages.reduce((s, p) => s + p.count * p.qtyPerUnit, 0).toLocaleString()} EA
{item.packages
.reduce((s, p) => s + p.count * p.qtyPerUnit, 0)
.toLocaleString()}{" "}
EA
</span>
</div>
{item.packages.map((pkg, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<div
key={idx}
className="flex items-center gap-1.5 text-[11px] text-gray-600"
>
<span>{pkg.unit.icon}</span>
<span>{pkg.count}{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA = {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA</span>
<span>
{pkg.count}
{pkg.unit.label} x {pkg.qtyPerUnit.toLocaleString()}EA
= {(pkg.count * pkg.qtyPerUnit).toLocaleString()}EA
</span>
</div>
))}
</div>
@@ -844,8 +925,7 @@ export function OutboundCartPage() {
</svg>
</button>
{!item.inspection_required &&
!inspResult?.completed && (
{!item.inspection_required && !inspResult?.completed && (
<button
onClick={() => handlePassInspection(item.rowKey)}
className="px-3 py-2.5 rounded-lg border border-gray-200 bg-gray-50 text-xs font-semibold text-gray-500 hover:bg-gray-100 active:scale-95 transition-all whitespace-nowrap"
@@ -879,7 +959,8 @@ export function OutboundCartPage() {
{hasUnfinishedRequiredInspection && (
<div className="mb-3 p-3 rounded-xl text-sm font-medium bg-amber-50 text-amber-700 border border-amber-200">
. .
.
.
</div>
)}
@@ -1044,32 +1125,65 @@ export function OutboundCartPage() {
<div className="absolute inset-0 bg-black/50" />
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden z-10">
{/* Header */}
<div className="px-6 py-5 text-center" style={{ background: "linear-gradient(to bottom, #4ade80, #16a34a)" }}>
<div
className="px-6 py-5 text-center"
style={{
background: "linear-gradient(to bottom, #4ade80, #16a34a)",
}}
>
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-3">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</div>
<h3 className="text-xl font-bold text-white"> </h3>
<p className="text-white/80 text-lg font-mono mt-1">{confirmResult.outboundNumber}</p>
<p className="text-white/80 text-lg font-mono mt-1">
{confirmResult.outboundNumber}
</p>
</div>
{/* Details */}
<div className="px-6 py-4">
<div className="flex items-center justify-between text-sm text-gray-500 mb-3">
<span>: <span className="font-semibold text-gray-900">{confirmResult.warehouse}</span></span>
<span>
:{" "}
<span className="font-semibold text-gray-900">
{confirmResult.warehouse}
</span>
</span>
<span>{confirmResult.date}</span>
</div>
<div className="text-xs font-semibold text-gray-400 mb-2"> ({confirmResult.items.length})</div>
<div className="text-xs font-semibold text-gray-400 mb-2">
({confirmResult.items.length})
</div>
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-2">
{confirmResult.items.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 bg-green-50 rounded-xl border border-green-100">
<div
key={item.id}
className="flex items-center justify-between p-3 bg-green-50 rounded-xl border border-green-100"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 truncate">{item.item_name}</p>
<p className="text-[11px] text-gray-400">{item.item_code}</p>
<p className="text-sm font-semibold text-gray-900 truncate">
{item.item_name}
</p>
<p className="text-[11px] text-gray-400">
{item.item_code}
</p>
</div>
<span className="text-sm font-bold text-green-600 ml-3">{item.outbound_qty?.toLocaleString()} EA</span>
<span className="text-sm font-bold text-green-600 ml-3">
{item.outbound_qty?.toLocaleString()} EA
</span>
</div>
))}
</div>
@@ -1083,7 +1197,10 @@ export function OutboundCartPage() {
router.push("/pop/outbound");
}}
className="w-full h-12 rounded-xl text-white font-bold text-base active:scale-[0.98] transition-all"
style={{ background: "linear-gradient(to bottom, #4ade80, #16a34a)", boxShadow: "0 4px 12px rgba(34,197,94,.3)" }}
style={{
background: "linear-gradient(to bottom, #4ade80, #16a34a)",
boxShadow: "0 4px 12px rgba(34,197,94,.3)",
}}
>
</button>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "../inventory/DateRangePicker";
@@ -58,15 +58,19 @@ type TabKey = "all" | "incoming" | "process" | "outgoing";
/* ------------------------------------------------------------------ */
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: "합격" };
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";
if (inspectionType?.includes("공정") || inspectionType?.includes("생산"))
return "process";
if (inspectionType?.includes("출하") || inspectionType?.includes("출고"))
return "outgoing";
return "all";
}
@@ -77,13 +81,23 @@ function classifyTab(inspectionType: string): TabKey {
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 [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 [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);
@@ -93,12 +107,16 @@ export function InspectionList() {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post("/table-management/tables/inspection_result_mng/data", {
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 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);
@@ -134,9 +152,16 @@ export function InspectionList() {
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" }) : "--:--",
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") : "-",
fullDate: r.inspection_date
? new Date(r.inspection_date).toLocaleString("ko-KR")
: "-",
};
});
@@ -163,11 +188,13 @@ export function InspectionList() {
setSelectedDetails([]);
return;
}
apiClient.post("/table-management/tables/inspection_result/data", {
apiClient
.post("/table-management/tables/inspection_result/data", {
page: 1,
pageSize: 100,
filters: { master_id: selectedItem.id },
}).then((res) => {
})
.then((res) => {
const rows: any[] = res.data?.data?.data ?? res.data?.data ?? [];
const details: DetailRow[] = rows
.filter((r: any) => r.master_id === selectedItem.id)
@@ -179,7 +206,8 @@ export function InspectionList() {
judgment: r.judgment || "",
}));
setSelectedDetails(details);
}).catch(() => setSelectedDetails([]));
})
.catch(() => setSelectedDetails([]));
}, [selectedItem]);
useEffect(() => {
@@ -194,13 +222,23 @@ export function InspectionList() {
}
if (keyword) {
const kw = keyword.toLowerCase();
if (!item.itemName.toLowerCase().includes(kw) && !item.itemCode.toLowerCase().includes(kw)) return false;
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;
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;
});
@@ -208,9 +246,12 @@ export function InspectionList() {
// 탭별 카운트
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,
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 }[] = [
@@ -228,13 +269,27 @@ export function InspectionList() {
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
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>
<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>
@@ -242,9 +297,18 @@ export function InspectionList() {
<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); }} />
<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>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
/
</label>
<input
type="text"
value={keyword}
@@ -254,7 +318,9 @@ export function InspectionList() {
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<select
value={judgmentFilter}
onChange={(e) => setJudgmentFilter(e.target.value)}
@@ -276,7 +342,12 @@ export function InspectionList() {
</button>
<button
onClick={() => { setDateFrom(new Date().toISOString().slice(0, 10)); setDateTo(new Date().toISOString().slice(0, 10)); setKeyword(""); setJudgmentFilter("전체"); }}
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"
>
@@ -288,11 +359,36 @@ export function InspectionList() {
{/* 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" />
<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>
@@ -328,7 +424,10 @@ export function InspectionList() {
{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
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">
@@ -342,11 +441,25 @@ export function InspectionList() {
</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
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>
<p className="text-sm font-medium text-gray-500 mb-1">
</p>
<p className="text-xs text-gray-400">
/
</p>
</div>
) : (
filtered.map((item) => {
@@ -360,14 +473,20 @@ export function InspectionList() {
<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)" }}
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}`}>
<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>
@@ -386,7 +505,9 @@ export function InspectionList() {
<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-violet-600 font-semibold mt-0.5">
{item.passRate}%
</p>
<p className="text-[10px] text-gray-400">{item.time}</p>
</div>
</div>
@@ -398,7 +519,10 @@ export function InspectionList() {
{/* Detail Bottom Sheet */}
{selectedItem && (
<div className="fixed inset-0 z-50" onClick={() => setSelectedItem(null)}>
<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"
@@ -409,18 +533,39 @@ export function InspectionList() {
<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" />
<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} />
<DetailField
label="검사번호"
value={selectedItem.inspectionNumber}
/>
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1"></p>
<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>
@@ -429,45 +574,76 @@ export function InspectionList() {
<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}`}>
<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-[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 || "-"} />
<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>
<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>
<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>
<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>
<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.defectDescription}
/>
)}
<DetailField label="검사자" value={selectedItem.inspector || "-"} />
<DetailField
label="검사자"
value={selectedItem.inspector || "-"}
/>
{selectedItem.memo && (
<DetailField label="비고" value={selectedItem.memo} />
)}
@@ -475,26 +651,39 @@ export function InspectionList() {
{/* 검사 항목별 결과 (디테일) */}
{selectedDetails.length > 0 && (
<div>
<p className="text-sm font-bold text-gray-900 mb-2"> </p>
<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
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}`}>
<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>
<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>
<p className="text-gray-700 font-semibold">
{d.measuredValue}
</p>
</div>
</div>
</div>
@@ -505,14 +694,16 @@ export function InspectionList() {
)}
</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
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>
);
}
@@ -526,7 +717,17 @@ function DetailField({ label, value }: { label: string; value: string }) {
);
}
function KpiCell({ icon, value, label, color }: { icon: string; value: string; label: string; color: string }) {
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>
@@ -536,7 +737,9 @@ function KpiCell({ icon, value, label, color }: { icon: string; value: string; l
>
{value}
</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">{label}</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">
{label}
</span>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
@@ -31,8 +31,10 @@ interface KpiData {
/* ------------------------------------------------------------------ */
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: "합격" };
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: "대기" };
}
@@ -43,8 +45,18 @@ const MENU_ITEMS = [
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
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",
@@ -58,7 +70,12 @@ const MENU_ITEMS = [
export function QualityHome() {
const router = useRouter();
const [kpi, setKpi] = useState<KpiData>({ todayTotal: 0, todayPass: 0, todayFail: 0, passRate: 0 });
const [kpi, setKpi] = useState<KpiData>({
todayTotal: 0,
todayPass: 0,
todayFail: 0,
passRate: 0,
});
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -68,23 +85,42 @@ export function QualityHome() {
setLoading(true);
const today = new Date().toISOString().slice(0, 10);
const res = await apiClient.post("/table-management/tables/inspection_result_mng/data", {
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 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 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 });
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 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 {
@@ -95,7 +131,12 @@ export function QualityHome() {
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" }) : "--:--",
time: r.created_date
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
})
: "--:--",
};
});
setRecentItems(top5);
@@ -116,12 +157,24 @@ export function QualityHome() {
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
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>
<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>
@@ -129,10 +182,26 @@ export function QualityHome() {
{/* 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" />
<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>
@@ -140,7 +209,9 @@ export function QualityHome() {
<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>
<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) => (
@@ -152,7 +223,10 @@ export function QualityHome() {
>
<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}` }}
style={{
background: item.gradient,
boxShadow: `0 4px 12px ${item.shadowColor}`,
}}
>
{item.icon}
</div>
@@ -168,18 +242,30 @@ export function QualityHome() {
<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>
<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>
<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>
<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" }}>
<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">
@@ -188,11 +274,15 @@ export function QualityHome() {
{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}`}>
<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 className="text-xs text-gray-400 mt-0.5 truncate">
{item.inspectionType}
</div>
</div>
</div>
))
@@ -204,7 +294,15 @@ export function QualityHome() {
);
}
function KpiCell({ value, label, color }: { value: string; label: string; color: string }) {
function KpiCell({
value,
label,
color,
}: {
value: string;
label: string;
color: string;
}) {
return (
<div className="flex flex-col items-center py-2">
<span
@@ -213,7 +311,9 @@ function KpiCell({ value, label, color }: { value: string; label: string; color:
>
{value}
</span>
<span className="text-[11px] font-medium text-gray-400 mt-1">{label}</span>
<span className="text-[11px] font-medium text-gray-400 mt-1">
{label}
</span>
</div>
);
}
@@ -1,2 +1,2 @@
export { QualityHome } from "./QualityHome";
export { InspectionList } from "./InspectionList";
export { QualityHome } from "./QualityHome";
+80 -29
View File
@@ -23,13 +23,13 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { dataApi } from "@/lib/api/data";
import type {
CartItem,
CartItemStatus,
CartItemWithId,
CartSyncStatus,
CartItemStatus,
} from "@/lib/registry/pop-components/types";
// ===== 반환 타입 =====
@@ -50,7 +50,12 @@ export interface UseCartSyncReturn {
addItem: (item: CartItem, rowKey: string) => void;
removeItem: (rowKey: string) => void;
updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => 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;
@@ -125,7 +130,9 @@ function cartItemToDbRecord(
quantity: String(item.quantity),
unit: "",
package_unit: item.packageUnit || "",
package_entries: item.packageEntries ? JSON.stringify(item.packageEntries) : "",
package_entries: item.packageEntries
? JSON.stringify(item.packageEntries)
: "",
status: item.status,
memo: item.memo || "",
};
@@ -138,7 +145,10 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
const serialize = (items: CartItemWithId[]) =>
items
.map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}:${JSON.stringify(item.row)}`)
.map(
(item) =>
`${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}:${JSON.stringify(item.row)}`,
)
.sort()
.join("|");
@@ -203,14 +213,19 @@ export function useCartSync(
// ----- 로컬 조작 (DB 미반영) -----
const addItem = useCallback(
(item: CartItem, rowKey: string) => {
const addItem = useCallback((item: CartItem, rowKey: string) => {
setCartItems((prev) => {
const exists = prev.find((i) => i.rowKey === rowKey);
if (exists) {
return prev.map((i) =>
i.rowKey === rowKey
? { ...i, quantity: item.quantity, packageUnit: item.packageUnit, packageEntries: item.packageEntries, row: item.row }
? {
...i,
quantity: item.quantity,
packageUnit: item.packageUnit,
packageEntries: item.packageEntries,
row: item.row,
}
: i,
);
}
@@ -224,16 +239,19 @@ export function useCartSync(
};
return [...prev, newItem];
});
},
[],
);
}, []);
const removeItem = useCallback((rowKey: string) => {
setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey));
}, []);
const updateItemQuantity = useCallback(
(rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => {
(
rowKey: string,
quantity: number,
packageUnit?: string,
packageEntries?: CartItem["packageEntries"],
) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey
@@ -255,9 +273,7 @@ export function useCartSync(
(rowKey: string, partialRow: Record<string, unknown>) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey
? { ...i, row: { ...i.row, ...partialRow } }
: i,
i.rowKey === rowKey ? { ...i, row: { ...i.row, ...partialRow } } : i,
),
);
},
@@ -275,11 +291,14 @@ export function useCartSync(
);
// ----- diff 계산 (백엔드 전송용) -----
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
const getChanges = useCallback(
(selectedColumns?: string[]): CartChanges => {
const currentScreenId = screenIdRef.current;
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
const toDeleteItems = savedItems.filter(
(s) => s.cartId && !cartRowKeys.has(s.rowKey),
);
const toCreateItems = cartItems.filter((c) => !c.cartId);
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
@@ -289,25 +308,40 @@ export function useCartSync(
if (!saved) return false;
// 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 (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status ||
rowChanged
);
});
return {
toCreate: toCreateItems.map((item) => cartItemToDbRecord(item, currentScreenId, selectedColumns)),
toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, ...cartItemToDbRecord(item, currentScreenId, selectedColumns) })),
toCreate: toCreateItems.map((item) =>
cartItemToDbRecord(item, currentScreenId, selectedColumns),
),
toUpdate: toUpdateItems.map((item) => ({
id: item.cartId,
...cartItemToDbRecord(item, currentScreenId, selectedColumns),
})),
toDelete: toDeleteItems.map((item) => item.cartId!),
};
}, [cartItems, savedItems]);
},
[cartItems, savedItems],
);
// ----- DB 저장 (일괄) -----
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
const saveToDb = useCallback(
async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving");
try {
const currentScreenId = screenIdRef.current;
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
const toDelete = savedItems.filter(
(s) => s.cartId && !cartRowKeys.has(s.rowKey),
);
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
const toCreate = cartItems.filter((c) => !c.cartId);
@@ -318,7 +352,8 @@ 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);
const rowChanged =
JSON.stringify(c.row) !== JSON.stringify(saved.row);
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
@@ -330,19 +365,33 @@ export function useCartSync(
const promises: Promise<unknown>[] = [];
for (const item of toDelete) {
promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
promises.push(
dataApi.updateRecord("cart_items", item.cartId!, {
status: "cancelled",
}),
);
}
for (const item of toCreate) {
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
const record = cartItemToDbRecord(
item,
currentScreenId,
selectedColumns,
);
// cart_items.id는 NOT NULL + 자동생성 없음 → UUID 직접 생성
const recordWithId = { id: crypto.randomUUID(), ...record };
promises.push(dataApi.createRecord("cart_items", recordWithId));
}
for (const item of toUpdate) {
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
const record = cartItemToDbRecord(
item,
currentScreenId,
selectedColumns,
);
promises.push(
dataApi.updateRecord("cart_items", item.cartId!, record),
);
}
await Promise.all(promises);
@@ -355,7 +404,9 @@ export function useCartSync(
setSyncStatus("dirty");
return false;
}
}, [cartItems, savedItems, loadFromDb]);
},
[cartItems, savedItems, loadFromDb],
);
// ----- 로컬 변경 취소 -----
const resetToSaved = useCallback(() => {