jskim-node #16
@@ -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;
|
||||
|
||||
12
frontend/app/(pop)/pop/inventory/history/page.tsx
Normal file
12
frontend/app/(pop)/pop/inventory/history/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { InOutHistory } from "@/components/pop/hardcoded/inventory";
|
||||
|
||||
export default function InOutHistoryPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="입출고관리">
|
||||
<InOutHistory />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
12
frontend/app/(pop)/pop/inventory/page.tsx
Normal file
12
frontend/app/(pop)/pop/inventory/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { InventoryHome } from "@/components/pop/hardcoded/inventory";
|
||||
|
||||
export default function InventoryPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="재고">
|
||||
<InventoryHome />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
12
frontend/app/(pop)/pop/quality/inspection/page.tsx
Normal file
12
frontend/app/(pop)/pop/quality/inspection/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { InspectionList } from "@/components/pop/hardcoded/quality";
|
||||
|
||||
export default function InspectionListPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="검사관리">
|
||||
<InspectionList />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
12
frontend/app/(pop)/pop/quality/page.tsx
Normal file
12
frontend/app/(pop)/pop/quality/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { QualityHome } from "@/components/pop/hardcoded/quality";
|
||||
|
||||
export default function QualityPage() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="품질">
|
||||
<QualityHome />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
82
frontend/components/pop/hardcoded/common/ConfirmModal.tsx
Normal file
82
frontend/components/pop/hardcoded/common/ConfirmModal.tsx
Normal file
@@ -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";
|
||||
|
||||
348
frontend/components/pop/hardcoded/inventory/DateRangePicker.tsx
Normal file
348
frontend/components/pop/hardcoded/inventory/DateRangePicker.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
747
frontend/components/pop/hardcoded/inventory/InOutHistory.tsx
Normal file
747
frontend/components/pop/hardcoded/inventory/InOutHistory.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
383
frontend/components/pop/hardcoded/inventory/InventoryHome.tsx
Normal file
383
frontend/components/pop/hardcoded/inventory/InventoryHome.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
2
frontend/components/pop/hardcoded/inventory/index.ts
Normal file
2
frontend/components/pop/hardcoded/inventory/index.ts
Normal file
@@ -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
745
frontend/components/pop/hardcoded/quality/InspectionList.tsx
Normal file
745
frontend/components/pop/hardcoded/quality/InspectionList.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
319
frontend/components/pop/hardcoded/quality/QualityHome.tsx
Normal file
319
frontend/components/pop/hardcoded/quality/QualityHome.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
2
frontend/components/pop/hardcoded/quality/index.ts
Normal file
2
frontend/components/pop/hardcoded/quality/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { InspectionList } from "./InspectionList";
|
||||
export { QualityHome } from "./QualityHome";
|
||||
@@ -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