Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -7,70 +7,70 @@
|
||||
* - 기타출고 → 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";
|
||||
|
||||
// 출고 목록 조회
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const {
|
||||
outbound_type,
|
||||
outbound_status,
|
||||
search_keyword,
|
||||
date_from,
|
||||
date_to,
|
||||
} = req.query;
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const {
|
||||
outbound_type,
|
||||
outbound_status,
|
||||
search_keyword,
|
||||
date_from,
|
||||
date_to,
|
||||
} = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`om.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`om.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_type && outbound_type !== "all") {
|
||||
conditions.push(`om.outbound_type = $${paramIdx}`);
|
||||
params.push(outbound_type);
|
||||
paramIdx++;
|
||||
}
|
||||
if (outbound_type && outbound_type !== "all") {
|
||||
conditions.push(`om.outbound_type = $${paramIdx}`);
|
||||
params.push(outbound_type);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_status && outbound_status !== "all") {
|
||||
conditions.push(`om.outbound_status = $${paramIdx}`);
|
||||
params.push(outbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
if (outbound_status && outbound_status !== "all") {
|
||||
conditions.push(`om.outbound_status = $${paramIdx}`);
|
||||
params.push(outbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
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})`
|
||||
);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
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})`,
|
||||
);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (date_from) {
|
||||
conditions.push(`om.outbound_date >= $${paramIdx}`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`om.outbound_date <= $${paramIdx}`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_from) {
|
||||
conditions.push(`om.outbound_date >= $${paramIdx}`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`om.outbound_date <= $${paramIdx}`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
const query = `
|
||||
SELECT
|
||||
om.*,
|
||||
wh.warehouse_name
|
||||
@@ -82,42 +82,52 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
ORDER BY om.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출고 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
logger.info("출고 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 등록 (다건)
|
||||
export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
|
||||
}
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, message: "출고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
await client.query("BEGIN");
|
||||
|
||||
const insertedRows: any[] = [];
|
||||
const insertedRows: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO outbound_mng (
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO outbound_mng (
|
||||
id, company_code, outbound_number, outbound_type, outbound_date,
|
||||
reference_number, customer_code, customer_name,
|
||||
item_code, item_name, specification, material, unit,
|
||||
@@ -138,165 +148,202 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
$26, $27, $28,
|
||||
NOW(), $29, $29, '출고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
outbound_number || item.outbound_number,
|
||||
item.outbound_type,
|
||||
outbound_date || item.outbound_date,
|
||||
item.reference_number || null,
|
||||
item.customer_code || null,
|
||||
item.customer_name || null,
|
||||
item.item_code || item.item_number || null,
|
||||
item.item_name || null,
|
||||
item.spec || item.specification || null,
|
||||
item.material || null,
|
||||
item.unit || "EA",
|
||||
item.outbound_qty || 0,
|
||||
item.unit_price || 0,
|
||||
item.total_amount || 0,
|
||||
item.lot_number || null,
|
||||
warehouse_code || item.warehouse_code || null,
|
||||
location_code || item.location_code || null,
|
||||
item.outbound_status || "대기",
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.source_type || null,
|
||||
item.sales_order_id || null,
|
||||
item.shipment_plan_id || null,
|
||||
item.item_info_id || null,
|
||||
item.destination_code || null,
|
||||
item.delivery_destination || null,
|
||||
item.delivery_address || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
[
|
||||
companyCode,
|
||||
outbound_number || item.outbound_number,
|
||||
item.outbound_type,
|
||||
outbound_date || item.outbound_date,
|
||||
item.reference_number || null,
|
||||
item.customer_code || null,
|
||||
item.customer_name || null,
|
||||
item.item_code || item.item_number || null,
|
||||
item.item_name || null,
|
||||
item.spec || item.specification || null,
|
||||
item.material || null,
|
||||
item.unit || "EA",
|
||||
item.outbound_qty || 0,
|
||||
item.unit_price || 0,
|
||||
item.total_amount || 0,
|
||||
item.lot_number || null,
|
||||
warehouse_code || item.warehouse_code || null,
|
||||
location_code || item.location_code || null,
|
||||
item.outbound_status || "대기",
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.source_type || null,
|
||||
item.sales_order_id || null,
|
||||
item.shipment_plan_id || null,
|
||||
item.item_info_id || null,
|
||||
item.destination_code || null,
|
||||
item.delivery_destination || null,
|
||||
item.delivery_address || null,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 업데이트 (inventory_stock): 출고 수량 차감
|
||||
const itemCode = item.item_code || item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const outQty = Number(item.outbound_qty) || 0;
|
||||
if (itemCode && outQty > 0) {
|
||||
const existingStock = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
// 재고 업데이트 (inventory_stock): 출고 수량 차감
|
||||
const itemCode = item.item_code || item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const outQty = Number(item.outbound_qty) || 0;
|
||||
if (itemCode && outQty > 0) {
|
||||
// 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 ROLLBACK)
|
||||
const stockCheck = await client.query(
|
||||
`SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as cur
|
||||
FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
[companyCode, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
const currentStock = parseFloat(stockCheck.rows[0]?.cur || "0");
|
||||
if (currentStock < outQty) {
|
||||
throw new Error(
|
||||
`재고 부족: ${item.item_name || itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (existingStock.rows.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
const existingStock = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
|
||||
if (existingStock.rows.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
|
||||
last_out_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[outQty, existingStock.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
[outQty, existingStock.rows[0].id],
|
||||
);
|
||||
} else {
|
||||
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
id, company_code, item_code, warehouse_code, location_code,
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
// 재고 이력 기록 (inventory_history)
|
||||
const afterStockRes = await client.query(
|
||||
`SELECT current_qty FROM inventory_stock
|
||||
// 재고 이력 기록 (inventory_history)
|
||||
const afterStockRes = await client.query(
|
||||
`SELECT current_qty FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
const afterQty = afterStockRes.rows[0]?.current_qty || '0';
|
||||
await client.query(
|
||||
`INSERT INTO inventory_history (
|
||||
[companyCode, itemCode, whCode || "", locCode || ""],
|
||||
);
|
||||
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") {
|
||||
const outQtyNum = Number(item.outbound_qty) || 0;
|
||||
await client.query(
|
||||
`UPDATE shipment_instruction_detail
|
||||
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영
|
||||
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]
|
||||
);
|
||||
const detailId = sidRes.rows[0]?.detail_id;
|
||||
if (detailId) {
|
||||
await client.query(
|
||||
`UPDATE sales_order_detail
|
||||
// 출하지시 상세의 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],
|
||||
);
|
||||
const detailId = sidRes.rows[0]?.detail_id;
|
||||
if (detailId) {
|
||||
await client.query(
|
||||
`UPDATE sales_order_detail
|
||||
SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text,
|
||||
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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
outbound_number,
|
||||
});
|
||||
logger.info("출고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
outbound_number,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 출고 등록 완료`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("출고 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 출고 등록 완료`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("출고 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 수정
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
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,
|
||||
} = req.body;
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
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,
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`UPDATE outbound_mng SET
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`UPDATE outbound_mng SET
|
||||
outbound_date = COALESCE($1, outbound_date),
|
||||
outbound_qty = COALESCE($2, outbound_qty),
|
||||
unit_price = COALESCE($3, unit_price),
|
||||
@@ -311,73 +358,89 @@ export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
updated_by = $11
|
||||
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: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
if (result.rowCount === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("출고 수정", { companyCode, userId, id });
|
||||
logger.info("출고 수정", { companyCode, userId, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 삭제
|
||||
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
const result = await pool.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
if (result.rowCount === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("출고 삭제", { companyCode, id });
|
||||
logger.info("출고 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 판매출고용: 출하지시 데이터 조회
|
||||
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
export async function getShipmentInstructions(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["si.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
const conditions: string[] = ["si.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`,
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
sid.id AS detail_id,
|
||||
si.id AS instruction_id,
|
||||
si.instruction_no,
|
||||
@@ -400,42 +463,45 @@ 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 });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 반품출고용: 발주(입고) 데이터 조회
|
||||
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
export async function getPurchaseOrders(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
// 입고된 것만 (반품 대상)
|
||||
conditions.push(
|
||||
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
|
||||
);
|
||||
// 입고된 것만 (반품 대상)
|
||||
conditions.push(
|
||||
`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})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`,
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, purchase_no, order_date, supplier_code, supplier_name,
|
||||
item_code, item_name, spec, material,
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
|
||||
@@ -445,137 +511,146 @@ 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 });
|
||||
} catch (error: any) {
|
||||
logger.error("발주 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("발주 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 기타출고용: 품목 데이터 조회
|
||||
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`,
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, item_number, item_name, size AS spec, material, unit,
|
||||
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
params
|
||||
);
|
||||
params,
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고번호 자동생성
|
||||
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);
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
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);
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (e: any) {
|
||||
logger.warn("선택한 채번규칙 사용 실패, 기본 채번으로 폴백", { ruleId, error: e.message });
|
||||
}
|
||||
}
|
||||
// 1순위: POP 화면설정에서 선택한 채번규칙 사용
|
||||
if (ruleId && ruleId !== "__none__") {
|
||||
try {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX)
|
||||
const pool = getPool();
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const prefix = `OUT-${yyyy}-`;
|
||||
// 2순위: 기본 하드코딩 채번 (OUT-YYYY-XXXX)
|
||||
const pool = getPool();
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const prefix = `OUT-${yyyy}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT outbound_number FROM outbound_mng
|
||||
const result = await pool.query(
|
||||
`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;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].outbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
let seq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].outbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("출고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("출고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고 목록 조회
|
||||
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
|
||||
ORDER BY warehouse_name`,
|
||||
[companyCode]
|
||||
);
|
||||
[companyCode],
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고별 위치 목록 조회
|
||||
export async function getLocations(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const warehouseCode = req.query.warehouse_code as string;
|
||||
const pool = getPool();
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const warehouseCode = req.query.warehouse_code as string;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT location_code, location_name, warehouse_code
|
||||
const result = await pool.query(
|
||||
`SELECT location_code, location_name, warehouse_code
|
||||
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 });
|
||||
} catch (error: any) {
|
||||
logger.error("위치 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("위치 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
@@ -9,32 +9,32 @@ router.use(authenticateToken);
|
||||
// ---- 검사 기준 조회 (item_inspection_info) ----
|
||||
// GET /api/pop/inspection-result/info?itemCode=ITEM-001&inspectionType=입고검사
|
||||
router.get("/info", async (req: Request, res: Response) => {
|
||||
const pool = getPool();
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const { itemCode, itemId, inspectionType } = req.query;
|
||||
const pool = getPool();
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const { itemCode, itemId, inspectionType } = req.query;
|
||||
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const conditions: string[] = ["company_code = $1", "is_active = 'Y'"];
|
||||
const params: unknown[] = [companyCode];
|
||||
let idx = 2;
|
||||
const conditions: string[] = ["company_code = $1", "is_active = 'Y'"];
|
||||
const params: unknown[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (itemCode) {
|
||||
conditions.push(`item_code = $${idx++}`);
|
||||
params.push(itemCode);
|
||||
}
|
||||
if (itemId) {
|
||||
conditions.push(`item_id = $${idx++}`);
|
||||
params.push(itemId);
|
||||
}
|
||||
if (inspectionType) {
|
||||
conditions.push(`inspection_type = $${idx++}`);
|
||||
params.push(inspectionType);
|
||||
}
|
||||
if (itemCode) {
|
||||
conditions.push(`item_code = $${idx++}`);
|
||||
params.push(itemCode);
|
||||
}
|
||||
if (itemId) {
|
||||
conditions.push(`item_id = $${idx++}`);
|
||||
params.push(itemId);
|
||||
}
|
||||
if (inspectionType) {
|
||||
conditions.push(`inspection_type = $${idx++}`);
|
||||
params.push(inspectionType);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
const sql = `
|
||||
SELECT id, item_id, item_code, item_name,
|
||||
inspection_type, inspection_item_name, inspection_standard,
|
||||
inspection_method, pass_criteria, is_required, sort_order, memo
|
||||
@@ -43,149 +43,272 @@ router.get("/info", async (req: Request, res: Response) => {
|
||||
ORDER BY sort_order, inspection_item_name
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(sql, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
try {
|
||||
const result = await pool.query(sql, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 검사 결과 조회 ----
|
||||
// GET /api/pop/inspection-result?referenceId=xxx&referenceTable=yyy&screenId=zzz
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const pool = getPool();
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const { referenceId, referenceTable, screenId } = req.query;
|
||||
const pool = getPool();
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const { referenceId, referenceTable, screenId } = req.query;
|
||||
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: unknown[] = [companyCode];
|
||||
let idx = 2;
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: unknown[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (referenceId) {
|
||||
conditions.push(`reference_id = $${idx++}`);
|
||||
params.push(referenceId);
|
||||
}
|
||||
if (referenceTable) {
|
||||
conditions.push(`reference_table = $${idx++}`);
|
||||
params.push(referenceTable);
|
||||
}
|
||||
if (screenId) {
|
||||
conditions.push(`screen_id = $${idx++}`);
|
||||
params.push(screenId);
|
||||
}
|
||||
if (referenceId) {
|
||||
conditions.push(`reference_id = $${idx++}`);
|
||||
params.push(referenceId);
|
||||
}
|
||||
if (referenceTable) {
|
||||
conditions.push(`reference_table = $${idx++}`);
|
||||
params.push(referenceTable);
|
||||
}
|
||||
if (screenId) {
|
||||
conditions.push(`screen_id = $${idx++}`);
|
||||
params.push(screenId);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM inspection_result
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY created_date DESC
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await pool.query(sql, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
try {
|
||||
const result = await pool.query(sql, params);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 검사 결과 저장 (INSERT or UPDATE) ----
|
||||
// ---- 검사번호 채번 (PC numberingRuleService 활용) ----
|
||||
async function generateInspectionNumber(companyCode: string): Promise<string> {
|
||||
// PC 채번 서비스 동적 import (순환 참조 방지)
|
||||
const { numberingRuleService } = await import(
|
||||
"../services/numberingRuleService"
|
||||
);
|
||||
|
||||
// 1) inspection_result_mng / inspection_number 채번 규칙 조회
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode,
|
||||
"inspection_result_mng",
|
||||
"inspection_number",
|
||||
);
|
||||
|
||||
if (rule && rule.ruleId) {
|
||||
// 2) PC API와 동일한 allocateCode 호출 → 실제 시퀀스 +1
|
||||
return await numberingRuleService.allocateCode(rule.ruleId, companyCode);
|
||||
}
|
||||
|
||||
// fallback: 채번 규칙 없으면 단순 SELECT MAX
|
||||
const { getPool } = await import("../database/db");
|
||||
const pool = getPool();
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `QI-${year}-`;
|
||||
const result = await pool.query(
|
||||
`SELECT inspection_number FROM inspection_result_mng
|
||||
WHERE company_code = $1 AND inspection_number LIKE $2
|
||||
ORDER BY inspection_number DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`],
|
||||
);
|
||||
let nextSeq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNumber = result.rows[0].inspection_number;
|
||||
const match = lastNumber.match(/(\d+)$/);
|
||||
if (match) nextSeq = parseInt(match[1], 10) + 1;
|
||||
}
|
||||
return `${prefix}${String(nextSeq).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// ---- 검사번호 채번 전용 엔드포인트 (검사 모달에서 검사 완료 시) ----
|
||||
// POST /api/pop/inspection-result/allocate-number
|
||||
router.post("/allocate-number", async (req: Request, res: Response) => {
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
try {
|
||||
const inspectionNumber = await generateInspectionNumber(companyCode);
|
||||
return res.json({ success: true, data: { inspectionNumber } });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 검사 결과 저장 (마스터 + 디테일 트랜잭션) ----
|
||||
// POST /api/pop/inspection-result
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const pool = getPool();
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const writer = (req as any).user?.userId;
|
||||
const pool = getPool();
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const writer = (req as any).user?.userId;
|
||||
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||
}
|
||||
|
||||
const {
|
||||
referenceTable,
|
||||
referenceId,
|
||||
screenId,
|
||||
itemId,
|
||||
itemCode,
|
||||
itemName,
|
||||
inspectionType,
|
||||
items, // 검사 항목별 결과 배열
|
||||
overallJudgment,
|
||||
memo,
|
||||
isCompleted,
|
||||
} = req.body;
|
||||
const {
|
||||
inspectionNumber: providedNumber, // 프론트에서 미리 채번한 번호 (있으면 재사용)
|
||||
referenceTable,
|
||||
referenceId,
|
||||
screenId,
|
||||
itemId,
|
||||
itemCode,
|
||||
itemName,
|
||||
inspectionType,
|
||||
items, // 검사 항목별 결과 배열
|
||||
overallJudgment,
|
||||
totalQty,
|
||||
goodQty,
|
||||
badQty,
|
||||
defectDescription,
|
||||
memo,
|
||||
inspector,
|
||||
supplierCode,
|
||||
supplierName,
|
||||
isCompleted,
|
||||
} = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "검사 항목이 없습니다" });
|
||||
}
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, message: "검사 항목이 없습니다" });
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 결과 삭제 (동일 referenceId + referenceTable 기준 덮어쓰기)
|
||||
if (referenceId && referenceTable) {
|
||||
await client.query(
|
||||
`DELETE FROM inspection_result
|
||||
// 1. 동일 referenceId + referenceTable 기존 마스터/디테일 삭제 (덮어쓰기)
|
||||
if (referenceId && referenceTable) {
|
||||
await client.query(
|
||||
`DELETE FROM inspection_result WHERE master_id IN (
|
||||
SELECT id FROM inspection_result_mng
|
||||
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3
|
||||
)`,
|
||||
[companyCode, referenceId, referenceTable],
|
||||
);
|
||||
await client.query(
|
||||
`DELETE FROM inspection_result_mng
|
||||
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`,
|
||||
[companyCode, referenceId, referenceTable]
|
||||
);
|
||||
}
|
||||
[companyCode, referenceId, referenceTable],
|
||||
);
|
||||
}
|
||||
|
||||
const insertedIds: string[] = [];
|
||||
for (const item of items) {
|
||||
const completedFlag = isCompleted ? "Y" : "N";
|
||||
const completedDate = isCompleted ? new Date() : null;
|
||||
const insertSql = `
|
||||
INSERT INTO inspection_result (
|
||||
company_code, writer,
|
||||
// 2. 검사번호 (프론트에서 미리 받았으면 재사용, 없으면 새로 채번)
|
||||
const inspectionNumber =
|
||||
providedNumber || (await generateInspectionNumber(companyCode));
|
||||
|
||||
// 3. 마스터 INSERT
|
||||
const completedFlag = isCompleted ? "Y" : "N";
|
||||
const completedDate = isCompleted ? new Date() : null;
|
||||
const masterResult = await client.query(
|
||||
`INSERT INTO inspection_result_mng (
|
||||
company_code, writer, inspection_number,
|
||||
reference_table, reference_id, screen_id,
|
||||
item_id, item_code, item_name,
|
||||
inspection_type, total_qty, good_qty, bad_qty,
|
||||
overall_judgment, defect_description, memo,
|
||||
inspector, inspection_date,
|
||||
supplier_code, supplier_name,
|
||||
is_completed, completed_date
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, $20, $21
|
||||
) RETURNING id, inspection_number`,
|
||||
[
|
||||
companyCode,
|
||||
writer,
|
||||
inspectionNumber,
|
||||
referenceTable || null,
|
||||
referenceId || null,
|
||||
screenId || null,
|
||||
itemId || null,
|
||||
itemCode || null,
|
||||
itemName || null,
|
||||
inspectionType || null,
|
||||
totalQty != null ? Number(totalQty) : null,
|
||||
goodQty != null ? Number(goodQty) : null,
|
||||
badQty != null ? Number(badQty) : null,
|
||||
overallJudgment || null,
|
||||
defectDescription || null,
|
||||
memo || null,
|
||||
inspector || writer,
|
||||
supplierCode || null,
|
||||
supplierName || null,
|
||||
completedFlag,
|
||||
completedDate,
|
||||
],
|
||||
);
|
||||
const masterId = masterResult.rows[0].id;
|
||||
|
||||
// 4. 디테일 N건 INSERT
|
||||
const insertedDetailIds: string[] = [];
|
||||
for (const item of items) {
|
||||
const detailResult = await client.query(
|
||||
`INSERT INTO inspection_result (
|
||||
company_code, writer, master_id,
|
||||
reference_table, reference_id, screen_id,
|
||||
inspection_info_id, item_id, item_code, item_name,
|
||||
inspection_type, inspection_item_name, inspection_standard, pass_criteria, is_required,
|
||||
measured_value, judgment, overall_judgment, memo,
|
||||
is_completed, completed_date
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const result = await client.query(insertSql, [
|
||||
companyCode,
|
||||
writer,
|
||||
referenceTable || null,
|
||||
referenceId || null,
|
||||
screenId || null,
|
||||
item.inspectionInfoId || null,
|
||||
itemId || item.itemId || null,
|
||||
itemCode || item.itemCode || null,
|
||||
itemName || item.itemName || null,
|
||||
inspectionType || item.inspectionType || null,
|
||||
item.inspectionItemName || null,
|
||||
item.inspectionStandard || null,
|
||||
item.passCriteria || null,
|
||||
item.isRequired || "Y",
|
||||
item.measuredValue || null,
|
||||
item.judgment || null,
|
||||
overallJudgment || null,
|
||||
memo || null,
|
||||
completedFlag,
|
||||
completedDate,
|
||||
]);
|
||||
insertedIds.push(result.rows[0].id);
|
||||
}
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21
|
||||
) RETURNING id`,
|
||||
[
|
||||
companyCode,
|
||||
writer,
|
||||
masterId,
|
||||
referenceTable || null,
|
||||
referenceId || null,
|
||||
screenId || null,
|
||||
item.inspectionInfoId || null,
|
||||
itemId || item.itemId || null,
|
||||
itemCode || item.itemCode || null,
|
||||
itemName || item.itemName || null,
|
||||
inspectionType || item.inspectionType || null,
|
||||
item.inspectionItemName || null,
|
||||
item.inspectionStandard || null,
|
||||
item.passCriteria || null,
|
||||
item.isRequired || "Y",
|
||||
item.measuredValue || null,
|
||||
item.judgment || null,
|
||||
overallJudgment || null,
|
||||
memo || null,
|
||||
completedFlag,
|
||||
completedDate,
|
||||
],
|
||||
);
|
||||
insertedDetailIds.push(detailResult.rows[0].id);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return res.json({ success: true, data: { ids: insertedIds } });
|
||||
} catch (err: any) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
masterId,
|
||||
inspectionNumber,
|
||||
detailIds: insertedDetailIds,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(500).json({ success: false, message: err.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { InOutHistory } from "@/components/pop/hardcoded/inventory";
|
||||
|
||||
export default function InOutHistoryPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="입출고관리">
|
||||
<InOutHistory />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { InventoryHome } from "@/components/pop/hardcoded/inventory";
|
||||
|
||||
export default function InventoryPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="재고">
|
||||
<InventoryHome />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { InspectionList } from "@/components/pop/hardcoded/quality";
|
||||
|
||||
export default function InspectionListPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="검사관리">
|
||||
<InspectionList />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { QualityHome } from "@/components/pop/hardcoded/quality";
|
||||
|
||||
export default function QualityPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="품질">
|
||||
<QualityHome />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type React from "react";
|
||||
|
||||
interface MenuIconItem {
|
||||
id: string;
|
||||
title: string;
|
||||
gradient: string;
|
||||
shadowColor: string;
|
||||
icon: React.ReactNode;
|
||||
href: string;
|
||||
id: string;
|
||||
title: string;
|
||||
gradient: string;
|
||||
shadowColor: string;
|
||||
icon: React.ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const MENU_ITEMS: MenuIconItem[] = [
|
||||
{
|
||||
id: "incoming",
|
||||
title: "입고",
|
||||
gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
|
||||
shadowColor: "rgba(59,130,246,.3)",
|
||||
icon: (
|
||||
<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",
|
||||
},
|
||||
{
|
||||
id: "outgoing",
|
||||
title: "출고",
|
||||
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>
|
||||
),
|
||||
href: "/pop/outbound",
|
||||
},
|
||||
{
|
||||
id: "production",
|
||||
title: "생산",
|
||||
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>
|
||||
),
|
||||
href: "/pop/production",
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
title: "품질",
|
||||
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>
|
||||
),
|
||||
href: "/pop/screens/quality",
|
||||
},
|
||||
{
|
||||
id: "equipment",
|
||||
title: "설비",
|
||||
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>
|
||||
),
|
||||
href: "/pop/screens/equipment",
|
||||
},
|
||||
{
|
||||
id: "inventory",
|
||||
title: "재고",
|
||||
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>
|
||||
),
|
||||
href: "/pop/screens/inventory",
|
||||
},
|
||||
// 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동
|
||||
{
|
||||
id: "safety",
|
||||
title: "안전관리",
|
||||
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>
|
||||
),
|
||||
href: "/pop/screens/safety",
|
||||
},
|
||||
{
|
||||
id: "incoming",
|
||||
title: "입고",
|
||||
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>
|
||||
),
|
||||
href: "/pop/inbound",
|
||||
},
|
||||
{
|
||||
id: "outgoing",
|
||||
title: "출고",
|
||||
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>
|
||||
),
|
||||
href: "/pop/outbound",
|
||||
},
|
||||
{
|
||||
id: "production",
|
||||
title: "생산",
|
||||
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>
|
||||
),
|
||||
href: "/pop/production",
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
title: "품질",
|
||||
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>
|
||||
),
|
||||
href: "/pop/quality",
|
||||
},
|
||||
{
|
||||
id: "equipment",
|
||||
title: "설비",
|
||||
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>
|
||||
),
|
||||
href: "/pop/screens/equipment",
|
||||
},
|
||||
{
|
||||
id: "inventory",
|
||||
title: "재고",
|
||||
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>
|
||||
),
|
||||
href: "/pop/inventory",
|
||||
},
|
||||
// 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동
|
||||
{
|
||||
id: "safety",
|
||||
title: "안전관리",
|
||||
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>
|
||||
),
|
||||
href: "/pop/screens/safety",
|
||||
},
|
||||
];
|
||||
|
||||
export function MenuIcons() {
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = (item: MenuIconItem) => {
|
||||
if (item.href === "#") {
|
||||
alert(`${item.title} 화면은 준비 중입니다.`);
|
||||
} else {
|
||||
router.push(item.href);
|
||||
}
|
||||
};
|
||||
const handleClick = (item: MenuIconItem) => {
|
||||
if (item.href === "#") {
|
||||
alert(`${item.title} 화면은 준비 중입니다.`);
|
||||
} else {
|
||||
router.push(item.href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-xl sm:text-[22px] font-bold text-gray-900 tracking-tight mb-4">
|
||||
메뉴
|
||||
</h2>
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-5 sm:gap-x-8 sm:gap-y-6 lg:gap-x-10">
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col items-center gap-2 w-16 sm:w-20 cursor-pointer group"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => handleClick(item)}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-110 group-active:scale-[0.93]"
|
||||
style={{
|
||||
background: item.gradient,
|
||||
boxShadow: `0 4px 12px ${item.shadowColor}`,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs sm:text-sm font-semibold text-gray-700">{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-xl sm:text-[22px] font-bold text-gray-900 tracking-tight mb-4">
|
||||
메뉴
|
||||
</h2>
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-5 sm:gap-x-8 sm:gap-y-6 lg:gap-x-10">
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col items-center gap-2 w-16 sm:w-20 cursor-pointer group"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => handleClick(item)}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-110 group-active:scale-[0.93]"
|
||||
style={{
|
||||
background: item.gradient,
|
||||
boxShadow: `0 4px 12px ${item.shadowColor}`,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-xs sm:text-sm font-semibold text-gray-700">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "primary" | "danger" | "success";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 공용 확인 모달 (native confirm() 대체)
|
||||
* 모바일 친화 디자인, bottom-sheet 스타일
|
||||
*/
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmText = "확인",
|
||||
cancelText = "취소",
|
||||
variant = "primary",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const confirmBg =
|
||||
variant === "danger"
|
||||
? "bg-gradient-to-b from-red-500 to-red-600 hover:from-red-600 hover:to-red-700"
|
||||
: variant === "success"
|
||||
? "bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700"
|
||||
: "bg-gradient-to-b from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100]" onClick={onCancel}>
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
|
||||
{/* Center modal */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div
|
||||
className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Body */}
|
||||
<div className="px-6 py-7 text-center">
|
||||
{title && (
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-3">{title}</h3>
|
||||
)}
|
||||
<p className="text-base text-gray-700 whitespace-pre-line leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 py-4 text-base font-semibold text-gray-600 hover:bg-gray-50 active:bg-gray-100 transition-colors"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<div className="w-px bg-gray-100" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 py-4 text-base font-bold text-white transition-all active:scale-[0.98] ${confirmBg}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +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";
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface DateRangePickerProps {
|
||||
from: string; // YYYY-MM-DD
|
||||
to: string; // YYYY-MM-DD
|
||||
onChange: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function firstDayOfMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 1).getDay(); // 0=Sun
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function fmtDisplay(dateStr: string): string {
|
||||
if (!dateStr) return "";
|
||||
const [y, m, d] = dateStr.split("-");
|
||||
return `${y}.${m}.${d}`;
|
||||
}
|
||||
|
||||
function isSame(a: string, b: string): boolean {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
function isBetween(date: string, from: string, to: string): boolean {
|
||||
return date >= from && date <= to;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
const MONTH_NAMES = [
|
||||
"1월",
|
||||
"2월",
|
||||
"3월",
|
||||
"4월",
|
||||
"5월",
|
||||
"6월",
|
||||
"7월",
|
||||
"8월",
|
||||
"9월",
|
||||
"10월",
|
||||
"11월",
|
||||
"12월",
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selecting, setSelecting] = useState<"from" | "to" | null>(null);
|
||||
const [tempFrom, setTempFrom] = useState(from);
|
||||
const [tempTo, setTempTo] = useState(to);
|
||||
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
const handleOpen = () => {
|
||||
setTempFrom(from);
|
||||
setTempTo(to);
|
||||
setSelecting("from");
|
||||
const d = from ? new Date(from) : new Date();
|
||||
setViewYear(d.getFullYear());
|
||||
setViewMonth(d.getMonth());
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleDayClick = (dateStr: string) => {
|
||||
if (selecting === "from") {
|
||||
setTempFrom(dateStr);
|
||||
setTempTo(dateStr); // 같은 날짜 = 당일
|
||||
setSelecting("to");
|
||||
} else {
|
||||
// to 선택
|
||||
if (dateStr < tempFrom) {
|
||||
// 시작일보다 이전 선택 → 시작일로 교체
|
||||
setTempFrom(dateStr);
|
||||
setTempTo(dateStr);
|
||||
setSelecting("to");
|
||||
} else {
|
||||
setTempTo(dateStr);
|
||||
onChange(tempFrom, dateStr);
|
||||
setOpen(false);
|
||||
setSelecting(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewYear(viewYear - 1);
|
||||
setViewMonth(11);
|
||||
} else setViewMonth(viewMonth - 1);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewYear(viewYear + 1);
|
||||
setViewMonth(0);
|
||||
} else setViewMonth(viewMonth + 1);
|
||||
};
|
||||
|
||||
// Quick select presets
|
||||
const today = fmt(new Date());
|
||||
const presets = [
|
||||
{ label: "오늘", from: today, to: today },
|
||||
{
|
||||
label: "이번주",
|
||||
from: fmt(
|
||||
new Date(
|
||||
new Date().setDate(new Date().getDate() - new Date().getDay()),
|
||||
),
|
||||
),
|
||||
to: today,
|
||||
},
|
||||
{
|
||||
label: "이번달",
|
||||
from: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}-01`,
|
||||
to: today,
|
||||
},
|
||||
];
|
||||
|
||||
// Display text
|
||||
const displayText =
|
||||
from && to
|
||||
? isSame(from, to)
|
||||
? fmtDisplay(from)
|
||||
: `${fmtDisplay(from)} ~ ${fmtDisplay(to)}`
|
||||
: "기간 선택";
|
||||
|
||||
// Build calendar grid
|
||||
const totalDays = daysInMonth(viewYear, viewMonth);
|
||||
const startDay = firstDayOfMonth(viewYear, viewMonth);
|
||||
const cells: (string | null)[] = [];
|
||||
for (let i = 0; i < startDay; i++) cells.push(null);
|
||||
for (let d = 1; d <= totalDays; d++) {
|
||||
const dateStr = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
cells.push(dateStr);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Trigger Button */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
|
||||
기간
|
||||
</label>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-left focus:outline-none focus:border-cyan-400 focus:ring-2 focus:ring-cyan-100 bg-white flex items-center justify-between gap-2"
|
||||
>
|
||||
<span
|
||||
className={from ? "text-gray-900 font-medium" : "text-gray-400"}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar Popup */}
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-2 z-50 bg-white rounded-2xl shadow-xl border border-gray-200 p-4 w-[320px]">
|
||||
{/* Header hint */}
|
||||
<p className="text-[10px] text-center text-gray-400 mb-2">
|
||||
{selecting === "from"
|
||||
? "시작일을 선택하세요"
|
||||
: "종료일을 선택하세요 (같은 날 = 당일)"}
|
||||
</p>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => {
|
||||
onChange(p.from, p.to);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex-1 py-1.5 rounded-lg text-[11px] font-semibold text-cyan-700 bg-cyan-50 hover:bg-cyan-100 active:scale-95 transition-all"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Month Navigation */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-100 active:scale-95"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{viewYear}년 {MONTH_NAMES[viewMonth]}
|
||||
</span>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-100 active:scale-95"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday Headers */}
|
||||
<div className="grid grid-cols-7 gap-0 mb-1">
|
||||
{WEEKDAYS.map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`text-center text-[10px] font-semibold py-1 ${i === 0 ? "text-red-400" : i === 6 ? "text-blue-400" : "text-gray-400"}`}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day Grid */}
|
||||
<div className="grid grid-cols-7 gap-0">
|
||||
{cells.map((dateStr, idx) => {
|
||||
if (!dateStr)
|
||||
return <div key={`empty-${idx}`} className="h-10" />;
|
||||
|
||||
const day = parseInt(dateStr.split("-")[2], 10);
|
||||
const dayOfWeek = new Date(dateStr).getDay();
|
||||
const isStart = isSame(dateStr, tempFrom);
|
||||
const isEnd = isSame(dateStr, tempTo);
|
||||
const isInRange =
|
||||
tempFrom && tempTo && isBetween(dateStr, tempFrom, tempTo);
|
||||
const isToday = isSame(dateStr, today);
|
||||
|
||||
let bgClass = "hover:bg-gray-100";
|
||||
let textClass =
|
||||
dayOfWeek === 0
|
||||
? "text-red-500"
|
||||
: dayOfWeek === 6
|
||||
? "text-blue-500"
|
||||
: "text-gray-700";
|
||||
|
||||
if (isStart || isEnd) {
|
||||
bgClass = "bg-cyan-600 text-white";
|
||||
textClass = "text-white";
|
||||
} else if (isInRange) {
|
||||
bgClass = "bg-cyan-50";
|
||||
textClass = "text-cyan-700";
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
onClick={() => handleDayClick(dateStr)}
|
||||
className={`h-10 flex items-center justify-center text-sm font-medium rounded-lg transition-all active:scale-90 ${bgClass} ${textClass}`}
|
||||
>
|
||||
<span className="relative">
|
||||
{day}
|
||||
{isToday && !isStart && !isEnd && (
|
||||
<span className="absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-cyan-500" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected Range Display */}
|
||||
{tempFrom && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 text-center">
|
||||
<span className="text-xs text-gray-500">
|
||||
{isSame(tempFrom, tempTo)
|
||||
? fmtDisplay(tempFrom)
|
||||
: `${fmtDisplay(tempFrom)} ~ ${fmtDisplay(tempTo)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { DateRangePicker } from "./DateRangePicker";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface HistoryItem {
|
||||
id: string;
|
||||
direction: "입고" | "출고";
|
||||
docNumber: string;
|
||||
type: string;
|
||||
itemName: string;
|
||||
itemCode: string;
|
||||
spec: string;
|
||||
qty: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
totalAmount: number;
|
||||
warehouse: string;
|
||||
warehouseCode: string;
|
||||
locationCode: string;
|
||||
lotNumber: string;
|
||||
partnerName: string;
|
||||
referenceNumber: string;
|
||||
writer: string;
|
||||
memo: string;
|
||||
status: string;
|
||||
statusColor: string;
|
||||
statusLabel: string;
|
||||
time: string;
|
||||
date: string;
|
||||
fullDate: string;
|
||||
}
|
||||
|
||||
interface KpiData {
|
||||
inbound: number;
|
||||
outbound: number;
|
||||
transfer: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
type TabKey = "all" | "inbound" | "outbound" | "transfer";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getStatusStyle(status: string | null): {
|
||||
color: string;
|
||||
label: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "완료":
|
||||
case "입고완료":
|
||||
case "출고완료":
|
||||
return { color: "text-green-600 bg-green-50", label: "완료" };
|
||||
case "대기":
|
||||
return { color: "text-amber-600 bg-amber-50", label: "대기" };
|
||||
case "진행중":
|
||||
case "부분입고":
|
||||
case "부분출고":
|
||||
return { color: "text-blue-600 bg-blue-50", label: "진행중" };
|
||||
default:
|
||||
return { color: "text-gray-600 bg-gray-50", label: status || "대기" };
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function InOutHistory() {
|
||||
const router = useRouter();
|
||||
|
||||
/* Filter state */
|
||||
const [dateFrom, setDateFrom] = useState(() =>
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const [dateTo, setDateTo] = useState(() =>
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [warehouse, setWarehouse] = useState("전체");
|
||||
const [warehouses, setWarehouses] = useState<
|
||||
{ code: string; name: string }[]
|
||||
>([]);
|
||||
|
||||
/* Data state */
|
||||
const [items, setItems] = useState<HistoryItem[]>([]);
|
||||
const [kpi, setKpi] = useState<KpiData>({
|
||||
inbound: 0,
|
||||
outbound: 0,
|
||||
transfer: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const [selectedItem, setSelectedItem] = useState<HistoryItem | null>(null);
|
||||
|
||||
/* Fetch warehouses */
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get("/outbound/warehouses")
|
||||
.then((res) => {
|
||||
const data = res.data?.data ?? [];
|
||||
setWarehouses(
|
||||
data.map((w: any) => ({
|
||||
code: w.warehouse_code || "",
|
||||
name: w.warehouse_name || "",
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
/* Fetch data */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (dateFrom) params.date_from = dateFrom;
|
||||
if (dateTo) params.date_to = dateTo;
|
||||
|
||||
const [inRes, outRes] = await Promise.all([
|
||||
apiClient.get("/receiving/list", { params }),
|
||||
apiClient.get("/outbound/list", { params }),
|
||||
]);
|
||||
|
||||
const inRows: any[] = inRes.data?.data ?? [];
|
||||
const outRows: any[] = outRes.data?.data ?? [];
|
||||
|
||||
const combined: HistoryItem[] = [
|
||||
...inRows.map((r: any, idx: number) => {
|
||||
const st = getStatusStyle(r.inbound_status);
|
||||
return {
|
||||
id: `in-${r.detail_id || r.id}-${idx}`,
|
||||
direction: "입고" as const,
|
||||
docNumber: r.inbound_number || "-",
|
||||
type: r.inbound_type || "입고",
|
||||
itemName: r.item_name || "-",
|
||||
itemCode: r.item_number || "",
|
||||
spec: r.specification || r.spec || "",
|
||||
qty: Number(r.inbound_qty || 0),
|
||||
unit: r.unit || "EA",
|
||||
unitPrice: Number(r.unit_price || 0),
|
||||
totalAmount: Number(r.total_amount || 0),
|
||||
warehouse: r.warehouse_name || "-",
|
||||
warehouseCode: r.warehouse_code || "",
|
||||
locationCode: r.location_code || "",
|
||||
lotNumber: r.lot_number || "",
|
||||
partnerName: r.supplier_name || "-",
|
||||
referenceNumber: r.reference_number || "",
|
||||
writer: r.writer || r.created_by || "",
|
||||
memo: r.memo || "",
|
||||
status: r.inbound_status || "",
|
||||
statusColor: st.color,
|
||||
statusLabel: st.label,
|
||||
time: r.created_date
|
||||
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--:--",
|
||||
date: r.inbound_date || r.created_date?.slice(0, 10) || "",
|
||||
fullDate: r.created_date
|
||||
? new Date(r.created_date).toLocaleString("ko-KR")
|
||||
: "-",
|
||||
};
|
||||
}),
|
||||
...outRows.map((r: any, idx: number) => {
|
||||
const st = getStatusStyle(r.outbound_status);
|
||||
return {
|
||||
id: `out-${r.id}-${idx}`,
|
||||
direction: "출고" as const,
|
||||
docNumber: r.outbound_number || "-",
|
||||
type: r.outbound_type || "출고",
|
||||
itemName: r.item_name || "-",
|
||||
itemCode: r.item_code || "",
|
||||
spec: r.specification || r.spec || "",
|
||||
qty: Number(r.outbound_qty || 0),
|
||||
unit: r.unit || "EA",
|
||||
unitPrice: Number(r.unit_price || 0),
|
||||
totalAmount: Number(r.total_amount || 0),
|
||||
warehouse: r.warehouse_name || "-",
|
||||
warehouseCode: r.warehouse_code || "",
|
||||
locationCode: r.location_code || "",
|
||||
lotNumber: r.lot_number || "",
|
||||
partnerName: r.customer_name || "-",
|
||||
referenceNumber: r.reference_number || "",
|
||||
writer: r.writer || r.created_by || "",
|
||||
memo: r.memo || "",
|
||||
status: r.outbound_status || "",
|
||||
statusColor: st.color,
|
||||
statusLabel: st.label,
|
||||
time: r.created_date
|
||||
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--:--",
|
||||
date: r.outbound_date || r.created_date?.slice(0, 10) || "",
|
||||
fullDate: r.created_date
|
||||
? new Date(r.created_date).toLocaleString("ko-KR")
|
||||
: "-",
|
||||
};
|
||||
}),
|
||||
].sort((a, b) => b.time.localeCompare(a.time));
|
||||
|
||||
setItems(combined);
|
||||
setKpi({
|
||||
inbound: inRows.length,
|
||||
outbound: outRows.length,
|
||||
transfer: 0,
|
||||
total: inRows.length + outRows.length,
|
||||
});
|
||||
} catch {
|
||||
setItems([]);
|
||||
setKpi({ inbound: 0, outbound: 0, transfer: 0, total: 0 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* Filter by tab + keyword + warehouse */
|
||||
const filtered = items.filter((item) => {
|
||||
if (activeTab === "inbound" && item.direction !== "입고") return false;
|
||||
if (activeTab === "outbound" && item.direction !== "출고") return false;
|
||||
if (activeTab === "transfer") return false; // 준비 중
|
||||
if (keyword) {
|
||||
const kw = keyword.toLowerCase();
|
||||
if (
|
||||
!item.itemName.toLowerCase().includes(kw) &&
|
||||
!item.itemCode.toLowerCase().includes(kw)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
if (warehouse !== "전체" && item.warehouse !== warehouse) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const TABS: {
|
||||
key: TabKey;
|
||||
label: string;
|
||||
count: number;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
{ key: "all", label: "전체", count: kpi.total },
|
||||
{ key: "inbound", label: "입고", count: kpi.inbound },
|
||||
{ key: "outbound", label: "출고", count: kpi.outbound },
|
||||
{ key: "transfer", label: "이동", count: kpi.transfer, disabled: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Back + Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/pop/inventory")}
|
||||
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
|
||||
입출고관리
|
||||
</h1>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
입고·출고 내역을 조회합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<DateRangePicker
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onChange={(f, t) => {
|
||||
setDateFrom(f);
|
||||
setDateTo(t);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
|
||||
품목검색
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="품목명 / 코드 검색"
|
||||
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
|
||||
창고
|
||||
</label>
|
||||
<select
|
||||
value={warehouse}
|
||||
onChange={(e) => setWarehouse(e.target.value)}
|
||||
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400 bg-white"
|
||||
>
|
||||
<option value="전체">전체</option>
|
||||
{warehouses.map((w) => (
|
||||
<option key={w.code} value={w.name}>
|
||||
{w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0 pb-[1px]">
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="h-[42px] px-4 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all"
|
||||
style={{ background: "linear-gradient(135deg,#06b6d4,#0e7490)" }}
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDateFrom(new Date().toISOString().slice(0, 10));
|
||||
setDateTo(new Date().toISOString().slice(0, 10));
|
||||
setKeyword("");
|
||||
setWarehouse("전체");
|
||||
}}
|
||||
className="h-[42px] w-[42px] rounded-lg text-sm font-semibold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
|
||||
<div className="grid grid-cols-4 gap-0">
|
||||
<KpiCell
|
||||
icon="📥"
|
||||
value={loading ? "-" : kpi.inbound.toLocaleString()}
|
||||
label="입고"
|
||||
color="text-blue-600"
|
||||
/>
|
||||
<KpiCell
|
||||
icon="📤"
|
||||
value={loading ? "-" : kpi.outbound.toLocaleString()}
|
||||
label="출고"
|
||||
color="text-green-600"
|
||||
/>
|
||||
<KpiCell
|
||||
icon="🔄"
|
||||
value={loading ? "-" : kpi.transfer.toLocaleString()}
|
||||
label="이동"
|
||||
color="text-gray-400"
|
||||
/>
|
||||
<KpiCell
|
||||
icon="📊"
|
||||
value={loading ? "-" : kpi.total.toLocaleString()}
|
||||
label="전체"
|
||||
color="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => !tab.disabled && setActiveTab(tab.key)}
|
||||
disabled={tab.disabled}
|
||||
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
|
||||
tab.disabled
|
||||
? "text-gray-300 bg-gray-50 cursor-not-allowed"
|
||||
: activeTab === tab.key
|
||||
? "text-white shadow-sm"
|
||||
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
|
||||
}`}
|
||||
style={
|
||||
!tab.disabled && activeTab === tab.key
|
||||
? { background: "linear-gradient(135deg,#06b6d4,#0e7490)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tab.label} {tab.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs font-semibold text-gray-500">
|
||||
입출고 내역
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">총 {filtered.length}건</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-2xl border border-gray-100 p-4 animate-pulse"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gray-100" />
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="h-4 bg-gray-100 rounded w-2/3" />
|
||||
<div className="h-3 bg-gray-50 rounded w-1/2" />
|
||||
</div>
|
||||
<div className="h-5 w-12 bg-gray-100 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<svg
|
||||
className="w-16 h-16 mb-4 opacity-20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-gray-500 mb-1">
|
||||
입출고 내역이 없습니다
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">검색 조건을 변경해보세요</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Direction icon */}
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0 ${
|
||||
item.direction === "입고" ? "" : ""
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
item.direction === "입고"
|
||||
? "linear-gradient(135deg,#3b82f6,#1d4ed8)"
|
||||
: "linear-gradient(135deg,#22c55e,#15803d)",
|
||||
}}
|
||||
>
|
||||
{item.direction === "입고" ? "📥" : "📤"}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-gray-900 truncate">
|
||||
{item.itemName}
|
||||
{item.itemCode ? ` (${item.itemCode})` : ""}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}
|
||||
>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{item.type} · {item.warehouse}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Qty + Time */}
|
||||
<div className="text-right shrink-0">
|
||||
<p
|
||||
className="text-base font-bold text-gray-900"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{item.qty.toLocaleString()}{" "}
|
||||
<span className="text-xs font-normal text-gray-400">
|
||||
{item.unit}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{item.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Bottom Sheet */}
|
||||
{selectedItem && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
>
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/40 transition-opacity" />
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className="relative w-full max-w-lg bg-white rounded-t-3xl shadow-2xl max-h-[85vh] overflow-y-auto animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Handle bar */}
|
||||
<div className="sticky top-0 bg-white pt-3 pb-2 flex justify-center rounded-t-3xl z-10">
|
||||
<div className="w-10 h-1 rounded-full bg-gray-300" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pb-4 border-b border-gray-100">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{selectedItem.direction === "입고" ? "입고" : "출고"} 상세 —{" "}
|
||||
{selectedItem.docNumber}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
{/* Row 1: 전표번호 + 구분 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailField label="전표번호" value={selectedItem.docNumber} />
|
||||
<DetailField label="구분" value={selectedItem.type} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: 일시 + 상태 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailField label="일시" value={selectedItem.fullDate} />
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
|
||||
상태
|
||||
</p>
|
||||
<span
|
||||
className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${selectedItem.statusColor}`}
|
||||
>
|
||||
{selectedItem.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100" />
|
||||
|
||||
{/* Row 3: 품목 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
|
||||
품목
|
||||
</p>
|
||||
<p className="text-base font-bold text-gray-900">
|
||||
{selectedItem.itemName}
|
||||
{selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""}
|
||||
{selectedItem.spec ? (
|
||||
<span className="text-sm font-normal text-gray-400 ml-2">
|
||||
{selectedItem.spec}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row 4: 수량 + LOT */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
|
||||
수량
|
||||
</p>
|
||||
<p
|
||||
className="text-xl font-bold text-cyan-600"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{selectedItem.qty.toLocaleString()}{" "}
|
||||
<span className="text-sm font-normal text-gray-400">
|
||||
{selectedItem.unit}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<DetailField
|
||||
label="LOT번호"
|
||||
value={selectedItem.lotNumber || "-"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100" />
|
||||
|
||||
{/* Row 5: 창고/위치 + 거래처 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
|
||||
창고 / 위치
|
||||
</p>
|
||||
<p className="text-sm font-bold text-gray-900">
|
||||
{selectedItem.warehouse}
|
||||
</p>
|
||||
{selectedItem.locationCode && (
|
||||
<p className="text-xs text-gray-400">
|
||||
{selectedItem.locationCode}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DetailField label="거래처" value={selectedItem.partnerName} />
|
||||
</div>
|
||||
|
||||
{/* Row 6: 작업자 + 비고 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailField
|
||||
label="작업자"
|
||||
value={selectedItem.writer || "-"}
|
||||
/>
|
||||
<DetailField label="비고" value={selectedItem.memo || "-"} />
|
||||
</div>
|
||||
|
||||
{/* Row 7: 참조번호 + 금액 (있을 때만) */}
|
||||
{(selectedItem.referenceNumber ||
|
||||
selectedItem.totalAmount > 0) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedItem.referenceNumber ? (
|
||||
<DetailField
|
||||
label="참조번호"
|
||||
value={selectedItem.referenceNumber}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{selectedItem.totalAmount > 0 ? (
|
||||
<DetailField
|
||||
label="금액"
|
||||
value={`${selectedItem.totalAmount.toLocaleString()}원`}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="w-full py-3 rounded-xl text-sm font-bold text-gray-600 bg-gray-100 active:scale-95 transition-all"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-cyan-600 mb-1">{label}</p>
|
||||
<p className="text-sm font-semibold text-gray-900">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCell({
|
||||
icon,
|
||||
value,
|
||||
label,
|
||||
color,
|
||||
}: {
|
||||
icon: string;
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-2">
|
||||
<span className="text-lg mb-0.5">{icon}</span>
|
||||
<span
|
||||
className={`text-xl sm:text-2xl font-extrabold leading-none ${color}`}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-gray-400 mt-1">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface RecentItem {
|
||||
id: string;
|
||||
time: string;
|
||||
direction: "입고" | "출고";
|
||||
type: string;
|
||||
itemName: string;
|
||||
qty: string;
|
||||
partnerName: string;
|
||||
statusColor: string;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
interface KpiData {
|
||||
todayInbound: number;
|
||||
todayOutbound: number;
|
||||
todayTotal: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getStatusStyle(status: string | null): {
|
||||
color: string;
|
||||
label: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "완료":
|
||||
case "입고완료":
|
||||
case "출고완료":
|
||||
return { color: "text-green-600 bg-green-50", label: "완료" };
|
||||
case "대기":
|
||||
return { color: "text-amber-600 bg-amber-50", label: "대기" };
|
||||
case "진행중":
|
||||
return { color: "text-blue-600 bg-blue-50", label: "진행중" };
|
||||
default:
|
||||
return { color: "text-gray-600 bg-gray-50", label: status || "대기" };
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Menu Items */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{
|
||||
id: "history",
|
||||
title: "입출고관리",
|
||||
gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
|
||||
shadowColor: "rgba(59,130,246,.3)",
|
||||
icon: (
|
||||
<svg
|
||||
className="w-7 h-7 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 7.5h-.75A2.25 2.25 0 004.5 9.75v7.5a2.25 2.25 0 002.25 2.25h7.5a2.25 2.25 0 002.25-2.25v-7.5a2.25 2.25 0 00-2.25-2.25h-.75m-6 3.75l3 3m0 0l3-3m-3 3V1.5m6 9h.75a2.25 2.25 0 012.25 2.25v7.5a2.25 2.25 0 01-2.25 2.25h-7.5a2.25 2.25 0 01-2.25-2.25v-7.5a2.25 2.25 0 012.25-2.25H12"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
href: "/pop/inventory/history",
|
||||
},
|
||||
{
|
||||
id: "adjust",
|
||||
title: "재고조정",
|
||||
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
|
||||
shadowColor: "rgba(245,158,11,.3)",
|
||||
icon: (
|
||||
<svg
|
||||
className="w-7 h-7 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
href: "#",
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function InventoryHome() {
|
||||
const router = useRouter();
|
||||
|
||||
const [kpi, setKpi] = useState<KpiData>({
|
||||
todayInbound: 0,
|
||||
todayOutbound: 0,
|
||||
todayTotal: 0,
|
||||
});
|
||||
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const [inRes, outRes] = await Promise.all([
|
||||
apiClient.get("/receiving/list", {
|
||||
params: { date_from: today, date_to: today },
|
||||
}),
|
||||
apiClient.get("/outbound/list", {
|
||||
params: { date_from: today, date_to: today },
|
||||
}),
|
||||
]);
|
||||
|
||||
const inRows: any[] = inRes.data?.data ?? [];
|
||||
const outRows: any[] = outRes.data?.data ?? [];
|
||||
|
||||
setKpi({
|
||||
todayInbound: inRows.length,
|
||||
todayOutbound: outRows.length,
|
||||
todayTotal: inRows.length + outRows.length,
|
||||
});
|
||||
|
||||
const combined: RecentItem[] = [
|
||||
...inRows.map((r: any, idx: number) => {
|
||||
const st = getStatusStyle(r.inbound_status);
|
||||
return {
|
||||
id: `in-${r.detail_id || r.id}-${idx}`,
|
||||
time: r.created_date
|
||||
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--:--",
|
||||
direction: "입고" as const,
|
||||
type: r.inbound_type || "입고",
|
||||
itemName: r.item_name || r.item_number || "-",
|
||||
qty: `${Number(r.inbound_qty || 0).toLocaleString()} ${r.unit || "EA"}`,
|
||||
partnerName: r.supplier_name || "-",
|
||||
statusColor: st.color,
|
||||
statusLabel: st.label,
|
||||
};
|
||||
}),
|
||||
...outRows.map((r: any, idx: number) => {
|
||||
const st = getStatusStyle(r.outbound_status);
|
||||
return {
|
||||
id: `out-${r.id}-${idx}`,
|
||||
time: r.created_date
|
||||
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--:--",
|
||||
direction: "출고" as const,
|
||||
type: r.outbound_type || "출고",
|
||||
itemName: r.item_name || r.item_code || "-",
|
||||
qty: `${Number(r.outbound_qty || 0).toLocaleString()} ${r.unit || "EA"}`,
|
||||
partnerName: r.customer_name || "-",
|
||||
statusColor: st.color,
|
||||
statusLabel: st.label,
|
||||
};
|
||||
}),
|
||||
]
|
||||
.sort((a, b) => b.time.localeCompare(a.time))
|
||||
.slice(0, 5);
|
||||
|
||||
setRecentItems(combined);
|
||||
} catch {
|
||||
// keep empty
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (item: (typeof MENU_ITEMS)[number]) => {
|
||||
if (item.href === "#") {
|
||||
alert(`${item.title} 화면은 준비 중입니다.`);
|
||||
} else {
|
||||
router.push(item.href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Back + Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/pop/home")}
|
||||
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
|
||||
재고
|
||||
</h1>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
입출고 현황 및 재고 관리
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
||||
<div className="grid grid-cols-3 gap-0">
|
||||
<KpiCell
|
||||
value={loading ? "-" : kpi.todayInbound.toLocaleString()}
|
||||
label="금일 입고"
|
||||
color="text-blue-600"
|
||||
/>
|
||||
<KpiCell
|
||||
value={loading ? "-" : kpi.todayOutbound.toLocaleString()}
|
||||
label="금일 출고"
|
||||
color="text-green-600"
|
||||
/>
|
||||
<KpiCell
|
||||
value={loading ? "-" : kpi.todayTotal.toLocaleString()}
|
||||
label="전체"
|
||||
color="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Icons */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-1 h-5 rounded-full bg-cyan-500" />
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-900">
|
||||
재고 관리
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-start gap-x-5 gap-y-4 sm:gap-x-6 sm:gap-y-5">
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col items-center gap-2 w-16 sm:w-[72px] cursor-pointer group"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-105 group-active:scale-[0.93]"
|
||||
style={{
|
||||
background: item.gradient,
|
||||
boxShadow: `0 4px 12px ${item.shadowColor}`,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-[11px] sm:text-xs font-semibold text-gray-700 text-center leading-tight">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<section>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-100">
|
||||
<h3 className="text-base sm:text-lg font-bold text-gray-900">
|
||||
최근 입출고
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">최근 5건</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3">
|
||||
<div className="w-[44px] h-4 bg-gray-100 rounded animate-pulse" />
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<div className="h-4 bg-gray-100 rounded w-3/4 animate-pulse" />
|
||||
<div className="h-3 bg-gray-50 rounded w-1/2 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
금일 입출고 내역이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
recentItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold text-gray-400 min-w-[44px] text-right"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{item.time}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.direction === "입고" ? "text-blue-600 bg-blue-50" : "text-green-600 bg-green-50"}`}
|
||||
>
|
||||
{item.direction}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 truncate">
|
||||
{item.itemName}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}
|
||||
>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5 truncate">
|
||||
{item.type} | {item.partnerName} | {item.qty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function KpiCell({
|
||||
value,
|
||||
label,
|
||||
color,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-2">
|
||||
<span
|
||||
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
|
||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-gray-400 mt-1">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { InOutHistory } from "./InOutHistory";
|
||||
export { InventoryHome } from "./InventoryHome";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,745 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { DateRangePicker } from "../inventory/DateRangePicker";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface InspectionRow {
|
||||
id: string;
|
||||
inspectionNumber: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
inspectionType: string;
|
||||
totalQty: number;
|
||||
goodQty: number;
|
||||
badQty: number;
|
||||
passRate: number;
|
||||
overallJudgment: string;
|
||||
defectDescription: string;
|
||||
referenceTable: string;
|
||||
referenceId: string;
|
||||
memo: string;
|
||||
inspector: string;
|
||||
supplierCode: string;
|
||||
supplierName: string;
|
||||
isCompleted: string;
|
||||
completedDate: string;
|
||||
createdDate: string;
|
||||
time: string;
|
||||
date: string;
|
||||
fullDate: string;
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
inspectionItemName: string;
|
||||
inspectionStandard: string;
|
||||
passCriteria: string;
|
||||
measuredValue: string;
|
||||
judgment: string;
|
||||
}
|
||||
|
||||
interface KpiData {
|
||||
total: number;
|
||||
pass: number;
|
||||
fail: number;
|
||||
waiting: number;
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
type TabKey = "all" | "incoming" | "process" | "outgoing";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getJudgmentStyle(judgment: string): { color: string; label: string } {
|
||||
if (judgment === "합격" || judgment === "pass")
|
||||
return { color: "text-green-600 bg-green-50", label: "합격" };
|
||||
if (judgment === "불합격" || judgment === "fail")
|
||||
return { color: "text-red-600 bg-red-50", label: "불합격" };
|
||||
return { color: "text-amber-600 bg-amber-50", label: "대기" };
|
||||
}
|
||||
|
||||
function classifyTab(inspectionType: string): TabKey {
|
||||
if (inspectionType?.includes("입고")) return "incoming";
|
||||
if (inspectionType?.includes("공정") || inspectionType?.includes("생산"))
|
||||
return "process";
|
||||
if (inspectionType?.includes("출하") || inspectionType?.includes("출고"))
|
||||
return "outgoing";
|
||||
return "all";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function InspectionList() {
|
||||
const router = useRouter();
|
||||
|
||||
const [dateFrom, setDateFrom] = useState(() =>
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const [dateTo, setDateTo] = useState(() =>
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [judgmentFilter, setJudgmentFilter] = useState("전체");
|
||||
|
||||
const [items, setItems] = useState<InspectionRow[]>([]);
|
||||
const [kpi, setKpi] = useState<KpiData>({
|
||||
total: 0,
|
||||
pass: 0,
|
||||
fail: 0,
|
||||
waiting: 0,
|
||||
passRate: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const [selectedItem, setSelectedItem] = useState<InspectionRow | null>(null);
|
||||
const [selectedDetails, setSelectedDetails] = useState<DetailRow[]>([]);
|
||||
|
||||
/* Fetch data — 마스터 (inspection_result_mng) */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/inspection_result_mng/data",
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
},
|
||||
);
|
||||
|
||||
const rows: any[] =
|
||||
res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
|
||||
|
||||
const filtered = rows.filter((r: any) => {
|
||||
const d = (r.inspection_date || r.created_date || "").slice(0, 10);
|
||||
if (!d) return true;
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const mapped: InspectionRow[] = filtered.map((r: any, idx: number) => {
|
||||
const overall = r.overall_judgment || "";
|
||||
const totalQ = Number(r.total_qty || 0);
|
||||
const goodQ = Number(r.good_qty || 0);
|
||||
const passRate = totalQ > 0 ? Math.round((goodQ / totalQ) * 100) : 0;
|
||||
return {
|
||||
id: `${r.id || idx}`,
|
||||
inspectionNumber: r.inspection_number || "",
|
||||
itemCode: r.item_code || "",
|
||||
itemName: r.item_name || "-",
|
||||
inspectionType: r.inspection_type || "",
|
||||
totalQty: totalQ,
|
||||
goodQty: goodQ,
|
||||
badQty: Number(r.bad_qty || 0),
|
||||
passRate,
|
||||
overallJudgment: overall,
|
||||
defectDescription: r.defect_description || "",
|
||||
referenceTable: r.reference_table || "",
|
||||
referenceId: r.reference_id || "",
|
||||
memo: r.memo || "",
|
||||
inspector: r.inspector || r.writer || "",
|
||||
supplierCode: r.supplier_code || "",
|
||||
supplierName: r.supplier_name || "",
|
||||
isCompleted: r.is_completed || "N",
|
||||
completedDate: r.completed_date || "",
|
||||
createdDate: r.created_date || "",
|
||||
time: r.inspection_date
|
||||
? new Date(r.inspection_date).toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--:--",
|
||||
date: (r.inspection_date || r.created_date || "").slice(0, 10),
|
||||
fullDate: r.inspection_date
|
||||
? new Date(r.inspection_date).toLocaleString("ko-KR")
|
||||
: "-",
|
||||
};
|
||||
});
|
||||
|
||||
setItems(mapped);
|
||||
|
||||
const total = mapped.length;
|
||||
const pass = mapped.filter((m) => m.overallJudgment === "합격").length;
|
||||
const fail = mapped.filter((m) => m.overallJudgment === "불합격").length;
|
||||
const waiting = total - pass - fail;
|
||||
const passRate = total > 0 ? Math.round((pass / total) * 100) : 0;
|
||||
|
||||
setKpi({ total, pass, fail, waiting, passRate });
|
||||
} catch {
|
||||
setItems([]);
|
||||
setKpi({ total: 0, pass: 0, fail: 0, waiting: 0, passRate: 0 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
/* Fetch detail when selected */
|
||||
useEffect(() => {
|
||||
if (!selectedItem) {
|
||||
setSelectedDetails([]);
|
||||
return;
|
||||
}
|
||||
apiClient
|
||||
.post("/table-management/tables/inspection_result/data", {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: { master_id: selectedItem.id },
|
||||
})
|
||||
.then((res) => {
|
||||
const rows: any[] = res.data?.data?.data ?? res.data?.data ?? [];
|
||||
const details: DetailRow[] = rows
|
||||
.filter((r: any) => r.master_id === selectedItem.id)
|
||||
.map((r: any) => ({
|
||||
inspectionItemName: r.inspection_item_name || "-",
|
||||
inspectionStandard: r.inspection_standard || r.pass_criteria || "-",
|
||||
passCriteria: r.pass_criteria || "-",
|
||||
measuredValue: r.measured_value || "-",
|
||||
judgment: r.judgment || "",
|
||||
}));
|
||||
setSelectedDetails(details);
|
||||
})
|
||||
.catch(() => setSelectedDetails([]));
|
||||
}, [selectedItem]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
/* Filter */
|
||||
const filtered = items.filter((item) => {
|
||||
if (activeTab !== "all") {
|
||||
const tab = classifyTab(item.inspectionType);
|
||||
if (tab !== activeTab) return false;
|
||||
}
|
||||
if (keyword) {
|
||||
const kw = keyword.toLowerCase();
|
||||
if (
|
||||
!item.itemName.toLowerCase().includes(kw) &&
|
||||
!item.itemCode.toLowerCase().includes(kw)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
if (judgmentFilter !== "전체") {
|
||||
const j = item.overallJudgment;
|
||||
if (judgmentFilter === "합격" && !(j === "합격" || j === "pass"))
|
||||
return false;
|
||||
if (judgmentFilter === "불합격" && !(j === "불합격" || j === "fail"))
|
||||
return false;
|
||||
if (
|
||||
judgmentFilter === "대기" &&
|
||||
(j === "합격" || j === "pass" || j === "불합격" || j === "fail")
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 탭별 카운트
|
||||
const counts = {
|
||||
all: items.length,
|
||||
incoming: items.filter((i) => classifyTab(i.inspectionType) === "incoming")
|
||||
.length,
|
||||
process: items.filter((i) => classifyTab(i.inspectionType) === "process")
|
||||
.length,
|
||||
outgoing: items.filter((i) => classifyTab(i.inspectionType) === "outgoing")
|
||||
.length,
|
||||
};
|
||||
|
||||
const TABS: { key: TabKey; label: string; count: number }[] = [
|
||||
{ key: "all", label: "전체", count: counts.all },
|
||||
{ key: "incoming", label: "입고검사", count: counts.incoming },
|
||||
{ key: "process", label: "공정검사", count: counts.process },
|
||||
{ key: "outgoing", label: "출하검사", count: counts.outgoing },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Back + Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/pop/quality")}
|
||||
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
|
||||
검사관리
|
||||
</h1>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
검사 결과 내역을 조회합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<DateRangePicker
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
onChange={(f, t) => {
|
||||
setDateFrom(f);
|
||||
setDateTo(t);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
|
||||
품목 / 검사번호
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="품목명 또는 검사번호"
|
||||
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-violet-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
|
||||
판정
|
||||
</label>
|
||||
<select
|
||||
value={judgmentFilter}
|
||||
onChange={(e) => setJudgmentFilter(e.target.value)}
|
||||
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-violet-400 bg-white"
|
||||
>
|
||||
<option value="전체">전체</option>
|
||||
<option value="합격">합격</option>
|
||||
<option value="불합격">불합격</option>
|
||||
<option value="대기">대기</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0 pb-[1px]">
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="h-[42px] px-4 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all"
|
||||
style={{ background: "linear-gradient(135deg,#8b5cf6,#6d28d9)" }}
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDateFrom(new Date().toISOString().slice(0, 10));
|
||||
setDateTo(new Date().toISOString().slice(0, 10));
|
||||
setKeyword("");
|
||||
setJudgmentFilter("전체");
|
||||
}}
|
||||
className="h-[42px] w-[42px] rounded-lg text-sm font-semibold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
|
||||
<div className="grid grid-cols-5 gap-0">
|
||||
<KpiCell
|
||||
icon="📋"
|
||||
value={loading ? "-" : kpi.total.toLocaleString()}
|
||||
label="전체"
|
||||
color="text-gray-900"
|
||||
/>
|
||||
<KpiCell
|
||||
icon="✅"
|
||||
value={loading ? "-" : kpi.pass.toLocaleString()}
|
||||
label="합격"
|
||||
color="text-green-600"
|
||||
/>
|
||||
<KpiCell
|
||||
icon="❌"
|
||||
value={loading ? "-" : kpi.fail.toLocaleString()}
|
||||
label="불합격"
|
||||
color="text-red-600"
|
||||
/>
|
||||
<KpiCell
|
||||
icon="⏳"
|
||||
value={loading ? "-" : kpi.waiting.toLocaleString()}
|
||||
label="대기"
|
||||
color="text-amber-600"
|
||||
/>
|
||||
<KpiCell
|
||||
icon="📊"
|
||||
value={loading ? "-" : `${kpi.passRate}%`}
|
||||
label="합격률"
|
||||
color="text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
|
||||
activeTab === tab.key
|
||||
? "text-white shadow-sm"
|
||||
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
|
||||
}`}
|
||||
style={
|
||||
activeTab === tab.key
|
||||
? { background: "linear-gradient(135deg,#8b5cf6,#6d28d9)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{tab.label} {tab.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-xs font-semibold text-gray-500">검사 내역</span>
|
||||
<span className="text-xs text-gray-400">총 {filtered.length}건</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-2xl border border-gray-100 p-4 animate-pulse"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gray-100" />
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="h-4 bg-gray-100 rounded w-2/3" />
|
||||
<div className="h-3 bg-gray-50 rounded w-1/2" />
|
||||
</div>
|
||||
<div className="h-5 w-12 bg-gray-100 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<svg
|
||||
className="w-16 h-16 mb-4 opacity-20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-gray-500 mb-1">
|
||||
검사 내역이 없습니다
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
기간을 변경하거나 입고/생산 시 검사를 진행해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((item) => {
|
||||
const js = getJudgmentStyle(item.overallJudgment);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0"
|
||||
style={{
|
||||
background: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold text-violet-600">
|
||||
{item.inspectionNumber}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${js.color}`}
|
||||
>
|
||||
{js.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-gray-900 truncate mt-0.5">
|
||||
{item.itemName}
|
||||
{item.itemCode ? ` (${item.itemCode})` : ""}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5 truncate">
|
||||
{item.inspectionType}
|
||||
{item.supplierName ? ` · ${item.supplierName}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-sm font-bold text-gray-700">
|
||||
<span className="text-green-600">{item.goodQty}</span>
|
||||
<span className="text-gray-300 mx-0.5">/</span>
|
||||
<span className="text-red-600">{item.badQty}</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-violet-600 font-semibold mt-0.5">
|
||||
{item.passRate}%
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400">{item.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Bottom Sheet */}
|
||||
{selectedItem && (
|
||||
<div
|
||||
className="fixed inset-0 z-50"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
<div
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-lg bg-white rounded-t-3xl shadow-2xl overflow-y-auto z-10"
|
||||
style={{ maxHeight: "85vh" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 bg-white pt-3 pb-2 flex justify-center rounded-t-3xl z-10">
|
||||
<div className="w-10 h-1 rounded-full bg-gray-300" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 pb-4 border-b border-gray-100">
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
{selectedItem.inspectionType} 상세 —{" "}
|
||||
{selectedItem.inspectionNumber}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailField
|
||||
label="검사번호"
|
||||
value={selectedItem.inspectionNumber}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-violet-600 mb-1">
|
||||
검사유형
|
||||
</p>
|
||||
<span className="inline-block text-xs font-bold px-2.5 py-1 rounded-full bg-violet-50 text-violet-700">
|
||||
{selectedItem.inspectionType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailField label="검사일시" value={selectedItem.fullDate} />
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-violet-600 mb-1">
|
||||
판정
|
||||
</p>
|
||||
<span
|
||||
className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${getJudgmentStyle(selectedItem.overallJudgment).color}`}
|
||||
>
|
||||
{getJudgmentStyle(selectedItem.overallJudgment).label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-100" />
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-violet-600 mb-1">
|
||||
품목
|
||||
</p>
|
||||
<p className="text-base font-bold text-gray-900">
|
||||
{selectedItem.itemName}
|
||||
{selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailField
|
||||
label="거래처"
|
||||
value={selectedItem.supplierName || "-"}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-violet-600 mb-1">
|
||||
합격률
|
||||
</p>
|
||||
<p className="text-lg font-bold text-violet-600">
|
||||
{selectedItem.passRate}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-violet-600 mb-1">
|
||||
검사수량
|
||||
</p>
|
||||
<p className="text-lg font-bold text-gray-900">
|
||||
{selectedItem.totalQty.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-green-600 mb-1">
|
||||
합격
|
||||
</p>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{selectedItem.goodQty.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-red-600 mb-1">
|
||||
불합격
|
||||
</p>
|
||||
<p className="text-lg font-bold text-red-600">
|
||||
{selectedItem.badQty.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedItem.defectDescription && (
|
||||
<DetailField
|
||||
label="불량내용"
|
||||
value={selectedItem.defectDescription}
|
||||
/>
|
||||
)}
|
||||
<DetailField
|
||||
label="검사자"
|
||||
value={selectedItem.inspector || "-"}
|
||||
/>
|
||||
{selectedItem.memo && (
|
||||
<DetailField label="비고" value={selectedItem.memo} />
|
||||
)}
|
||||
|
||||
{/* 검사 항목별 결과 (디테일) */}
|
||||
{selectedDetails.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-900 mb-2">
|
||||
검사 항목별 결과
|
||||
</p>
|
||||
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||||
{selectedDetails.map((d, idx) => {
|
||||
const dj = getJudgmentStyle(d.judgment);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white rounded-lg p-3 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{d.inspectionItemName}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${dj.color}`}
|
||||
>
|
||||
{dj.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-400">기준</span>
|
||||
<p className="text-gray-700">
|
||||
{d.inspectionStandard}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">측정값</span>
|
||||
<p className="text-gray-700 font-semibold">
|
||||
{d.measuredValue}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="w-full py-3 rounded-xl text-sm font-bold text-gray-600 bg-gray-100 active:scale-95 transition-all"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-violet-600 mb-1">{label}</p>
|
||||
<p className="text-sm font-semibold text-gray-900 break-all">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCell({
|
||||
icon,
|
||||
value,
|
||||
label,
|
||||
color,
|
||||
}: {
|
||||
icon: string;
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-2">
|
||||
<span className="text-lg mb-0.5">{icon}</span>
|
||||
<span
|
||||
className={`text-xl sm:text-2xl font-extrabold leading-none ${color}`}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-gray-400 mt-1">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface RecentItem {
|
||||
id: string;
|
||||
itemName: string;
|
||||
itemCode: string;
|
||||
inspectionType: string;
|
||||
judgment: string;
|
||||
judgmentColor: string;
|
||||
judgmentLabel: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface KpiData {
|
||||
todayTotal: number;
|
||||
todayPass: number;
|
||||
todayFail: number;
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getJudgmentStyle(j: string): { color: string; label: string } {
|
||||
if (j === "합격" || j === "pass")
|
||||
return { color: "text-green-600 bg-green-50", label: "합격" };
|
||||
if (j === "불합격" || j === "fail")
|
||||
return { color: "text-red-600 bg-red-50", label: "불합격" };
|
||||
return { color: "text-amber-600 bg-amber-50", label: "대기" };
|
||||
}
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{
|
||||
id: "inspection",
|
||||
title: "검사관리",
|
||||
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
|
||||
shadowColor: "rgba(139,92,246,.3)",
|
||||
icon: (
|
||||
<svg
|
||||
className="w-7 h-7 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
href: "/pop/quality/inspection",
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function QualityHome() {
|
||||
const router = useRouter();
|
||||
|
||||
const [kpi, setKpi] = useState<KpiData>({
|
||||
todayTotal: 0,
|
||||
todayPass: 0,
|
||||
todayFail: 0,
|
||||
passRate: 0,
|
||||
});
|
||||
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const res = await apiClient.post(
|
||||
"/table-management/tables/inspection_result_mng/data",
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
},
|
||||
);
|
||||
|
||||
const rows: any[] =
|
||||
res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
|
||||
const todayRows = rows.filter(
|
||||
(r: any) => (r.created_date || "").slice(0, 10) === today,
|
||||
);
|
||||
|
||||
const total = todayRows.length;
|
||||
const pass = todayRows.filter(
|
||||
(r: any) =>
|
||||
r.overall_judgment === "합격" || r.overall_judgment === "pass",
|
||||
).length;
|
||||
const fail = todayRows.filter(
|
||||
(r: any) =>
|
||||
r.overall_judgment === "불합격" || r.overall_judgment === "fail",
|
||||
).length;
|
||||
const passRate = total > 0 ? Math.round((pass / total) * 100) : 0;
|
||||
|
||||
setKpi({
|
||||
todayTotal: total,
|
||||
todayPass: pass,
|
||||
todayFail: fail,
|
||||
passRate,
|
||||
});
|
||||
|
||||
// 최근 5건
|
||||
const sorted = [...rows].sort((a: any, b: any) =>
|
||||
(b.created_date || "").localeCompare(a.created_date || ""),
|
||||
);
|
||||
const top5 = sorted.slice(0, 5).map((r: any, idx: number) => {
|
||||
const js = getJudgmentStyle(r.overall_judgment || r.judgment || "");
|
||||
return {
|
||||
id: `${r.id || idx}`,
|
||||
itemName: r.item_name || "-",
|
||||
itemCode: r.item_code || "",
|
||||
inspectionType: r.inspection_type || "",
|
||||
judgment: r.overall_judgment || "",
|
||||
judgmentColor: js.color,
|
||||
judgmentLabel: js.label,
|
||||
time: r.created_date
|
||||
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--:--",
|
||||
};
|
||||
});
|
||||
setRecentItems(top5);
|
||||
} catch {
|
||||
// empty
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Back + Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/pop/home")}
|
||||
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
|
||||
품질
|
||||
</h1>
|
||||
<p className="text-xs text-gray-400 mt-0.5">검사 현황 및 품질 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
||||
<div className="grid grid-cols-4 gap-0">
|
||||
<KpiCell
|
||||
value={loading ? "-" : kpi.todayTotal.toLocaleString()}
|
||||
label="금일 검사"
|
||||
color="text-gray-900"
|
||||
/>
|
||||
<KpiCell
|
||||
value={loading ? "-" : kpi.todayPass.toLocaleString()}
|
||||
label="합격"
|
||||
color="text-green-600"
|
||||
/>
|
||||
<KpiCell
|
||||
value={loading ? "-" : kpi.todayFail.toLocaleString()}
|
||||
label="불합격"
|
||||
color="text-red-600"
|
||||
/>
|
||||
<KpiCell
|
||||
value={loading ? "-" : `${kpi.passRate}%`}
|
||||
label="합격률"
|
||||
color="text-violet-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Icons */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-1 h-5 rounded-full bg-violet-500" />
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-900">
|
||||
품질 관리
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-start gap-x-5 gap-y-4 sm:gap-x-6 sm:gap-y-5">
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col items-center gap-2 w-16 sm:w-[72px] cursor-pointer group"
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={() => router.push(item.href)}
|
||||
>
|
||||
<div
|
||||
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-105 group-active:scale-[0.93]"
|
||||
style={{
|
||||
background: item.gradient,
|
||||
boxShadow: `0 4px 12px ${item.shadowColor}`,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-[11px] sm:text-xs font-semibold text-gray-700 text-center leading-tight">
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<section>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-100">
|
||||
<h3 className="text-base sm:text-lg font-bold text-gray-900">
|
||||
최근 검사
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">최근 5건</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : recentItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
최근 검사 내역이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
recentItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold text-gray-400 min-w-[44px] text-right"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{item.time}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900 truncate">
|
||||
{item.itemName}
|
||||
{item.itemCode ? ` (${item.itemCode})` : ""}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full shrink-0 ${item.judgmentColor}`}
|
||||
>
|
||||
{item.judgmentLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5 truncate">
|
||||
{item.inspectionType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCell({
|
||||
value,
|
||||
label,
|
||||
color,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-2">
|
||||
<span
|
||||
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
|
||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-gray-400 mt-1">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { InspectionList } from "./InspectionList";
|
||||
export { QualityHome } from "./QualityHome";
|
||||
+342
-271
@@ -23,342 +23,413 @@
|
||||
|
||||
"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,
|
||||
CartItemWithId,
|
||||
CartSyncStatus,
|
||||
CartItemStatus,
|
||||
CartItem,
|
||||
CartItemStatus,
|
||||
CartItemWithId,
|
||||
CartSyncStatus,
|
||||
} from "@/lib/registry/pop-components/types";
|
||||
|
||||
// ===== 반환 타입 =====
|
||||
|
||||
export interface CartChanges {
|
||||
toCreate: Record<string, unknown>[];
|
||||
toUpdate: Record<string, unknown>[];
|
||||
toDelete: (string | number)[];
|
||||
toCreate: Record<string, unknown>[];
|
||||
toUpdate: Record<string, unknown>[];
|
||||
toDelete: (string | number)[];
|
||||
}
|
||||
|
||||
export interface UseCartSyncReturn {
|
||||
cartItems: CartItemWithId[];
|
||||
savedItems: CartItemWithId[];
|
||||
syncStatus: CartSyncStatus;
|
||||
cartCount: number;
|
||||
isDirty: boolean;
|
||||
loading: boolean;
|
||||
cartItems: CartItemWithId[];
|
||||
savedItems: CartItemWithId[];
|
||||
syncStatus: CartSyncStatus;
|
||||
cartCount: number;
|
||||
isDirty: boolean;
|
||||
loading: boolean;
|
||||
|
||||
addItem: (item: CartItem, rowKey: string) => void;
|
||||
removeItem: (rowKey: string) => void;
|
||||
updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void;
|
||||
isItemInCart: (rowKey: string) => boolean;
|
||||
getCartItem: (rowKey: string) => CartItemWithId | undefined;
|
||||
addItem: (item: CartItem, rowKey: string) => void;
|
||||
removeItem: (rowKey: string) => void;
|
||||
updateItemQuantity: (
|
||||
rowKey: string,
|
||||
quantity: number,
|
||||
packageUnit?: string,
|
||||
packageEntries?: CartItem["packageEntries"],
|
||||
) => void;
|
||||
updateItemRow: (rowKey: string, partialRow: Record<string, unknown>) => void;
|
||||
isItemInCart: (rowKey: string) => boolean;
|
||||
getCartItem: (rowKey: string) => CartItemWithId | undefined;
|
||||
|
||||
getChanges: (selectedColumns?: string[]) => CartChanges;
|
||||
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
|
||||
loadFromDb: () => Promise<void>;
|
||||
resetToSaved: () => void;
|
||||
getChanges: (selectedColumns?: string[]) => CartChanges;
|
||||
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
|
||||
loadFromDb: () => Promise<void>;
|
||||
resetToSaved: () => void;
|
||||
}
|
||||
|
||||
// ===== DB 행 -> CartItemWithId 변환 =====
|
||||
|
||||
function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
|
||||
let rowData: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = dbRow.row_data;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
rowData = JSON.parse(raw);
|
||||
} else if (typeof raw === "object" && raw !== null) {
|
||||
rowData = raw as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
rowData = {};
|
||||
}
|
||||
let rowData: Record<string, unknown> = {};
|
||||
try {
|
||||
const raw = dbRow.row_data;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
rowData = JSON.parse(raw);
|
||||
} else if (typeof raw === "object" && raw !== null) {
|
||||
rowData = raw as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
rowData = {};
|
||||
}
|
||||
|
||||
let packageEntries: CartItem["packageEntries"] | undefined;
|
||||
try {
|
||||
const raw = dbRow.package_entries;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
packageEntries = JSON.parse(raw);
|
||||
} else if (Array.isArray(raw)) {
|
||||
packageEntries = raw;
|
||||
}
|
||||
} catch {
|
||||
packageEntries = undefined;
|
||||
}
|
||||
let packageEntries: CartItem["packageEntries"] | undefined;
|
||||
try {
|
||||
const raw = dbRow.package_entries;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
packageEntries = JSON.parse(raw);
|
||||
} else if (Array.isArray(raw)) {
|
||||
packageEntries = raw;
|
||||
}
|
||||
} catch {
|
||||
packageEntries = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
row: rowData,
|
||||
quantity: Number(dbRow.quantity) || 0,
|
||||
packageUnit: (dbRow.package_unit as string) || undefined,
|
||||
packageEntries,
|
||||
cartId: (dbRow.id as string) || undefined,
|
||||
sourceTable: (dbRow.source_table as string) || "",
|
||||
rowKey: (dbRow.row_key as string) || "",
|
||||
status: ((dbRow.status as string) || "in_cart") as CartItemStatus,
|
||||
_origin: "db",
|
||||
memo: (dbRow.memo as string) || undefined,
|
||||
};
|
||||
return {
|
||||
row: rowData,
|
||||
quantity: Number(dbRow.quantity) || 0,
|
||||
packageUnit: (dbRow.package_unit as string) || undefined,
|
||||
packageEntries,
|
||||
cartId: (dbRow.id as string) || undefined,
|
||||
sourceTable: (dbRow.source_table as string) || "",
|
||||
rowKey: (dbRow.row_key as string) || "",
|
||||
status: ((dbRow.status as string) || "in_cart") as CartItemStatus,
|
||||
_origin: "db",
|
||||
memo: (dbRow.memo as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== CartItemWithId -> DB 저장용 레코드 변환 =====
|
||||
|
||||
function cartItemToDbRecord(
|
||||
item: CartItemWithId,
|
||||
screenId: string,
|
||||
selectedColumns?: string[],
|
||||
item: CartItemWithId,
|
||||
screenId: string,
|
||||
selectedColumns?: string[],
|
||||
): Record<string, unknown> {
|
||||
const rowData =
|
||||
selectedColumns && selectedColumns.length > 0
|
||||
? Object.fromEntries(
|
||||
Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)),
|
||||
)
|
||||
: item.row;
|
||||
const rowData =
|
||||
selectedColumns && selectedColumns.length > 0
|
||||
? Object.fromEntries(
|
||||
Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)),
|
||||
)
|
||||
: item.row;
|
||||
|
||||
return {
|
||||
cart_type: "pop",
|
||||
screen_id: screenId,
|
||||
source_table: item.sourceTable,
|
||||
row_key: item.rowKey,
|
||||
row_data: JSON.stringify(rowData),
|
||||
quantity: String(item.quantity),
|
||||
unit: "",
|
||||
package_unit: item.packageUnit || "",
|
||||
package_entries: item.packageEntries ? JSON.stringify(item.packageEntries) : "",
|
||||
status: item.status,
|
||||
memo: item.memo || "",
|
||||
};
|
||||
return {
|
||||
cart_type: "pop",
|
||||
screen_id: screenId,
|
||||
source_table: item.sourceTable,
|
||||
row_key: item.rowKey,
|
||||
row_data: JSON.stringify(rowData),
|
||||
quantity: String(item.quantity),
|
||||
unit: "",
|
||||
package_unit: item.packageUnit || "",
|
||||
package_entries: item.packageEntries
|
||||
? JSON.stringify(item.packageEntries)
|
||||
: "",
|
||||
status: item.status,
|
||||
memo: item.memo || "",
|
||||
};
|
||||
}
|
||||
|
||||
// ===== dirty check: 두 배열의 내용이 동일한지 비교 =====
|
||||
|
||||
function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
|
||||
const serialize = (items: CartItemWithId[]) =>
|
||||
items
|
||||
.map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const serialize = (items: CartItemWithId[]) =>
|
||||
items
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}:${JSON.stringify(item.row)}`,
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
return serialize(a) === serialize(b);
|
||||
return serialize(a) === serialize(b);
|
||||
}
|
||||
|
||||
// ===== 훅 본체 =====
|
||||
|
||||
export function useCartSync(
|
||||
screenId: string,
|
||||
sourceTable: string,
|
||||
screenId: string,
|
||||
sourceTable: string,
|
||||
): UseCartSyncReturn {
|
||||
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
||||
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
||||
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const screenIdRef = useRef(screenId);
|
||||
const sourceTableRef = useRef(sourceTable);
|
||||
screenIdRef.current = screenId;
|
||||
sourceTableRef.current = sourceTable;
|
||||
const screenIdRef = useRef(screenId);
|
||||
const sourceTableRef = useRef(sourceTable);
|
||||
screenIdRef.current = screenId;
|
||||
sourceTableRef.current = sourceTable;
|
||||
|
||||
// ----- DB에서 장바구니 로드 -----
|
||||
const loadFromDb = useCallback(async () => {
|
||||
if (!screenId || !sourceTable) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await dataApi.getTableData("cart_items", {
|
||||
size: 500,
|
||||
filters: {
|
||||
screen_id: screenId,
|
||||
cart_type: "pop",
|
||||
status: "in_cart",
|
||||
},
|
||||
});
|
||||
// ----- DB에서 장바구니 로드 -----
|
||||
const loadFromDb = useCallback(async () => {
|
||||
if (!screenId || !sourceTable) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await dataApi.getTableData("cart_items", {
|
||||
size: 500,
|
||||
filters: {
|
||||
screen_id: screenId,
|
||||
cart_type: "pop",
|
||||
status: "in_cart",
|
||||
},
|
||||
});
|
||||
|
||||
const items = (result.data || []).map(dbRowToCartItem);
|
||||
setSavedItems(items);
|
||||
setCartItems(items);
|
||||
setSyncStatus("clean");
|
||||
} catch (err) {
|
||||
console.error("[useCartSync] DB 로드 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [screenId, sourceTable]);
|
||||
const items = (result.data || []).map(dbRowToCartItem);
|
||||
setSavedItems(items);
|
||||
setCartItems(items);
|
||||
setSyncStatus("clean");
|
||||
} catch (err) {
|
||||
console.error("[useCartSync] DB 로드 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [screenId, sourceTable]);
|
||||
|
||||
// 마운트 시 자동 로드
|
||||
useEffect(() => {
|
||||
loadFromDb();
|
||||
}, [loadFromDb]);
|
||||
// 마운트 시 자동 로드
|
||||
useEffect(() => {
|
||||
loadFromDb();
|
||||
}, [loadFromDb]);
|
||||
|
||||
// ----- dirty 상태 계산 -----
|
||||
const isDirty = !areItemsEqual(cartItems, savedItems);
|
||||
// ----- dirty 상태 계산 -----
|
||||
const isDirty = !areItemsEqual(cartItems, savedItems);
|
||||
|
||||
// isDirty 변경 시 syncStatus 자동 갱신
|
||||
useEffect(() => {
|
||||
if (syncStatus !== "saving") {
|
||||
setSyncStatus(isDirty ? "dirty" : "clean");
|
||||
}
|
||||
}, [isDirty, syncStatus]);
|
||||
// isDirty 변경 시 syncStatus 자동 갱신
|
||||
useEffect(() => {
|
||||
if (syncStatus !== "saving") {
|
||||
setSyncStatus(isDirty ? "dirty" : "clean");
|
||||
}
|
||||
}, [isDirty, syncStatus]);
|
||||
|
||||
// ----- 로컬 조작 (DB 미반영) -----
|
||||
// ----- 로컬 조작 (DB 미반영) -----
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
const newItem: CartItemWithId = {
|
||||
...item,
|
||||
cartId: undefined,
|
||||
sourceTable: sourceTableRef.current,
|
||||
rowKey,
|
||||
status: "in_cart",
|
||||
_origin: "local",
|
||||
};
|
||||
return [...prev, newItem];
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
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,
|
||||
);
|
||||
}
|
||||
const newItem: CartItemWithId = {
|
||||
...item,
|
||||
cartId: undefined,
|
||||
sourceTable: sourceTableRef.current,
|
||||
rowKey,
|
||||
status: "in_cart",
|
||||
_origin: "local",
|
||||
};
|
||||
return [...prev, newItem];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback((rowKey: string) => {
|
||||
setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey));
|
||||
}, []);
|
||||
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"]) => {
|
||||
setCartItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.rowKey === rowKey
|
||||
? {
|
||||
...i,
|
||||
quantity,
|
||||
...(packageUnit !== undefined && { packageUnit }),
|
||||
...(packageEntries !== undefined && { packageEntries }),
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const updateItemQuantity = useCallback(
|
||||
(
|
||||
rowKey: string,
|
||||
quantity: number,
|
||||
packageUnit?: string,
|
||||
packageEntries?: CartItem["packageEntries"],
|
||||
) => {
|
||||
setCartItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.rowKey === rowKey
|
||||
? {
|
||||
...i,
|
||||
quantity,
|
||||
...(packageUnit !== undefined && { packageUnit }),
|
||||
...(packageEntries !== undefined && { packageEntries }),
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const isItemInCart = useCallback(
|
||||
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
|
||||
[cartItems],
|
||||
);
|
||||
// row 객체에 임의 필드를 부분 업데이트 (예: inspectionResult)
|
||||
const updateItemRow = useCallback(
|
||||
(rowKey: string, partialRow: Record<string, unknown>) => {
|
||||
setCartItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.rowKey === rowKey ? { ...i, row: { ...i.row, ...partialRow } } : i,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getCartItem = useCallback(
|
||||
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
|
||||
[cartItems],
|
||||
);
|
||||
const isItemInCart = useCallback(
|
||||
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
|
||||
[cartItems],
|
||||
);
|
||||
|
||||
// ----- diff 계산 (백엔드 전송용) -----
|
||||
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
|
||||
const currentScreenId = screenIdRef.current;
|
||||
const getCartItem = useCallback(
|
||||
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
|
||||
[cartItems],
|
||||
);
|
||||
|
||||
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
|
||||
const toCreateItems = cartItems.filter((c) => !c.cartId);
|
||||
// ----- diff 계산 (백엔드 전송용) -----
|
||||
const getChanges = useCallback(
|
||||
(selectedColumns?: string[]): CartChanges => {
|
||||
const currentScreenId = screenIdRef.current;
|
||||
|
||||
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
|
||||
const toUpdateItems = cartItems.filter((c) => {
|
||||
if (!c.cartId) return false;
|
||||
const saved = savedMap.get(c.rowKey);
|
||||
if (!saved) return false;
|
||||
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status;
|
||||
});
|
||||
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||
const toDeleteItems = savedItems.filter(
|
||||
(s) => s.cartId && !cartRowKeys.has(s.rowKey),
|
||||
);
|
||||
const toCreateItems = cartItems.filter((c) => !c.cartId);
|
||||
|
||||
return {
|
||||
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]);
|
||||
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
|
||||
const toUpdateItems = cartItems.filter((c) => {
|
||||
if (!c.cartId) return false;
|
||||
const saved = savedMap.get(c.rowKey);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
// ----- DB 저장 (일괄) -----
|
||||
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
|
||||
setSyncStatus("saving");
|
||||
try {
|
||||
const currentScreenId = screenIdRef.current;
|
||||
return {
|
||||
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],
|
||||
);
|
||||
|
||||
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
|
||||
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||
const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
|
||||
// ----- DB 저장 (일괄) -----
|
||||
const saveToDb = useCallback(
|
||||
async (selectedColumns?: string[]): Promise<boolean> => {
|
||||
setSyncStatus("saving");
|
||||
try {
|
||||
const currentScreenId = screenIdRef.current;
|
||||
|
||||
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
|
||||
const toCreate = cartItems.filter((c) => !c.cartId);
|
||||
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
|
||||
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||
const toDelete = savedItems.filter(
|
||||
(s) => s.cartId && !cartRowKeys.has(s.rowKey),
|
||||
);
|
||||
|
||||
// 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것
|
||||
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
|
||||
const toUpdate = cartItems.filter((c) => {
|
||||
if (!c.cartId) return false;
|
||||
const saved = savedMap.get(c.rowKey);
|
||||
if (!saved) return false;
|
||||
return (
|
||||
c.quantity !== saved.quantity ||
|
||||
c.packageUnit !== saved.packageUnit ||
|
||||
c.status !== saved.status
|
||||
);
|
||||
});
|
||||
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
|
||||
const toCreate = cartItems.filter((c) => !c.cartId);
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
// 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것
|
||||
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
|
||||
const toUpdate = cartItems.filter((c) => {
|
||||
if (!c.cartId) return false;
|
||||
const saved = savedMap.get(c.rowKey);
|
||||
if (!saved) return false;
|
||||
const rowChanged =
|
||||
JSON.stringify(c.row) !== JSON.stringify(saved.row);
|
||||
return (
|
||||
c.quantity !== saved.quantity ||
|
||||
c.packageUnit !== saved.packageUnit ||
|
||||
c.status !== saved.status ||
|
||||
rowChanged
|
||||
);
|
||||
});
|
||||
|
||||
for (const item of toDelete) {
|
||||
promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
|
||||
}
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
for (const item of toCreate) {
|
||||
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 toDelete) {
|
||||
promises.push(
|
||||
dataApi.updateRecord("cart_items", item.cartId!, {
|
||||
status: "cancelled",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of toUpdate) {
|
||||
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
|
||||
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
|
||||
}
|
||||
for (const item of toCreate) {
|
||||
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));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
for (const item of toUpdate) {
|
||||
const record = cartItemToDbRecord(
|
||||
item,
|
||||
currentScreenId,
|
||||
selectedColumns,
|
||||
);
|
||||
promises.push(
|
||||
dataApi.updateRecord("cart_items", item.cartId!, record),
|
||||
);
|
||||
}
|
||||
|
||||
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
|
||||
await loadFromDb();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[useCartSync] DB 저장 실패:", err);
|
||||
setSyncStatus("dirty");
|
||||
return false;
|
||||
}
|
||||
}, [cartItems, savedItems, loadFromDb]);
|
||||
await Promise.all(promises);
|
||||
|
||||
// ----- 로컬 변경 취소 -----
|
||||
const resetToSaved = useCallback(() => {
|
||||
setCartItems(savedItems);
|
||||
setSyncStatus("clean");
|
||||
}, [savedItems]);
|
||||
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
|
||||
await loadFromDb();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[useCartSync] DB 저장 실패:", err);
|
||||
setSyncStatus("dirty");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[cartItems, savedItems, loadFromDb],
|
||||
);
|
||||
|
||||
return {
|
||||
cartItems,
|
||||
savedItems,
|
||||
syncStatus,
|
||||
cartCount: cartItems.length,
|
||||
isDirty,
|
||||
loading,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItemQuantity,
|
||||
isItemInCart,
|
||||
getCartItem,
|
||||
getChanges,
|
||||
saveToDb,
|
||||
loadFromDb,
|
||||
resetToSaved,
|
||||
};
|
||||
// ----- 로컬 변경 취소 -----
|
||||
const resetToSaved = useCallback(() => {
|
||||
setCartItems(savedItems);
|
||||
setSyncStatus("clean");
|
||||
}, [savedItems]);
|
||||
|
||||
return {
|
||||
cartItems,
|
||||
savedItems,
|
||||
syncStatus,
|
||||
cartCount: cartItems.length,
|
||||
isDirty,
|
||||
loading,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItemQuantity,
|
||||
updateItemRow,
|
||||
isItemInCart,
|
||||
getCartItem,
|
||||
getChanges,
|
||||
saveToDb,
|
||||
loadFromDb,
|
||||
resetToSaved,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user