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

- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단
- 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선
- 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거
- 검사관리+입출고관리: 신규 화면 (quality, inventory)
- 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
This commit is contained in:
SeongHyun Kim
2026-04-09 14:38:28 +09:00
parent 1b62dae277
commit 327b4d01c2
25 changed files with 15182 additions and 12185 deletions
+430 -372
View File
@@ -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,182 +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) {
// 재고 사전 검증: 부족 시 즉시 에러 (트랜잭션 ROLLBACK)
const stockCheck = await client.query(
`SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) as cur
// 재고 업데이트 (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 || '']
);
const currentStock = parseFloat(stockCheck.rows[0]?.cur || '0');
if (currentStock < outQty) {
throw new Error(
`재고 부족: ${item.item_name || itemCode} (창고 ${whCode || '미지정'}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`
);
}
[companyCode, itemCode, whCode || "", locCode || ""],
);
const currentStock = parseFloat(stockCheck.rows[0]?.cur || "0");
if (currentStock < outQty) {
throw new Error(
`재고 부족: ${item.item_name || itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${currentStock}, 요청 출고 ${outQty}`,
);
}
const existingStock = await client.query(
`SELECT id FROM inventory_stock
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 || '']
);
[companyCode, itemCode, whCode || "", locCode || ""],
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
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),
@@ -328,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,
@@ -417,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,
@@ -462,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
+222 -217
View File
@@ -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,174 +43,179 @@ 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 });
}
});
// ---- 검사번호 채번 (PC numberingRuleService 활용) ----
async function generateInspectionNumber(companyCode: string): Promise<string> {
// PC 채번 서비스 동적 import (순환 참조 방지)
const { numberingRuleService } = await import("../services/numberingRuleService");
// 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"
);
// 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);
}
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
// 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")}`;
[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 });
}
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 {
inspectionNumber: providedNumber, // 프론트에서 미리 채번한 번호 (있으면 재사용)
referenceTable,
referenceId,
screenId,
itemId,
itemCode,
itemName,
inspectionType,
items, // 검사 항목별 결과 배열
overallJudgment,
totalQty,
goodQty,
badQty,
defectDescription,
memo,
inspector,
supplierCode,
supplierName,
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");
// 1. 동일 referenceId + referenceTable 기존 마스터/디테일 삭제 (덮어쓰기)
if (referenceId && referenceTable) {
await client.query(
`DELETE FROM inspection_result WHERE master_id IN (
// 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
[companyCode, referenceId, referenceTable],
);
await client.query(
`DELETE FROM inspection_result_mng
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`,
[companyCode, referenceId, referenceTable]
);
}
[companyCode, referenceId, referenceTable],
);
}
// 2. 검사번호 (프론트에서 미리 받았으면 재사용, 없으면 새로 채번)
const inspectionNumber = providedNumber || await generateInspectionNumber(companyCode);
// 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 (
// 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,
@@ -222,37 +227,37 @@ router.post("/", async (req: Request, res: Response) => {
) 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;
[
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 (
// 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,
@@ -262,48 +267,48 @@ router.post("/", async (req: Request, res: Response) => {
) VALUES (
$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);
}
[
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: {
masterId,
inspectionNumber,
detailIds: insertedDetailIds,
},
});
} 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;
@@ -4,9 +4,9 @@ import { PopShell } from "@/components/pop/hardcoded";
import { InOutHistory } from "@/components/pop/hardcoded/inventory";
export default function InOutHistoryPage() {
return (
<PopShell showBanner={false} title="입출고관리">
<InOutHistory />
</PopShell>
);
return (
<PopShell showBanner={false} title="입출고관리">
<InOutHistory />
</PopShell>
);
}
+5 -5
View File
@@ -4,9 +4,9 @@ import { PopShell } from "@/components/pop/hardcoded";
import { InventoryHome } from "@/components/pop/hardcoded/inventory";
export default function InventoryPage() {
return (
<PopShell showBanner={false} title="재고">
<InventoryHome />
</PopShell>
);
return (
<PopShell showBanner={false} title="재고">
<InventoryHome />
</PopShell>
);
}
@@ -4,9 +4,9 @@ import { PopShell } from "@/components/pop/hardcoded";
import { InspectionList } from "@/components/pop/hardcoded/quality";
export default function InspectionListPage() {
return (
<PopShell showBanner={false} title="검사관리">
<InspectionList />
</PopShell>
);
return (
<PopShell showBanner={false} title="검사관리">
<InspectionList />
</PopShell>
);
}
+5 -5
View File
@@ -4,9 +4,9 @@ import { PopShell } from "@/components/pop/hardcoded";
import { QualityHome } from "@/components/pop/hardcoded/quality";
export default function QualityPage() {
return (
<PopShell showBanner={false} title="품질">
<QualityHome />
</PopShell>
);
return (
<PopShell showBanner={false} title="품질">
<QualityHome />
</PopShell>
);
}
+205 -129
View File
@@ -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/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",
},
{
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>
);
}
@@ -3,14 +3,14 @@
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;
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "primary" | "danger" | "success";
onConfirm: () => void;
onCancel: () => void;
}
/**
@@ -18,65 +18,65 @@ export interface ConfirmModalProps {
* 모바일 친화 디자인, bottom-sheet 스타일
*/
export function ConfirmModal({
open,
title,
message,
confirmText = "확인",
cancelText = "취소",
variant = "primary",
onConfirm,
onCancel,
open,
title,
message,
confirmText = "확인",
cancelText = "취소",
variant = "primary",
onConfirm,
onCancel,
}: ConfirmModalProps) {
if (!open) return null;
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";
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" />
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>
{/* 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>
);
{/* 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
+22 -6
View File
@@ -1,9 +1,25 @@
export { PopShell } from "./PopShell";
export {
InboundCart,
InboundTypeSelect,
PurchaseInbound,
SupplierModal,
} from "./inbound";
export { InOutHistory, InventoryHome } from "./inventory";
export { KpiCarousel } from "./KpiCarousel";
export { MenuIcons } from "./MenuIcons";
export {
CustomerModal,
OutboundCartPage,
OutboundTypeSelect,
SalesOutbound,
} from "./outbound";
export { PopShell } from "./PopShell";
export {
AcceptProcessModal,
DefectTypeModal,
ProcessTimer,
ProcessWork,
WorkOrderList,
} from "./production";
export { InspectionList, QualityHome } from "./quality";
export { RecentActivity } from "./RecentActivity";
export { InboundTypeSelect, PurchaseInbound, SupplierModal, InboundCart } from "./inbound";
export { OutboundTypeSelect, SalesOutbound, CustomerModal, OutboundCartPage } from "./outbound";
export { WorkOrderList, ProcessWork, ProcessTimer, DefectTypeModal, AcceptProcessModal } from "./production";
export { InventoryHome, InOutHistory } from "./inventory";
export { QualityHome, InspectionList } from "./quality";
@@ -1,15 +1,15 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
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;
from: string; // YYYY-MM-DD
to: string; // YYYY-MM-DD
onChange: (from: string, to: string) => void;
}
/* ------------------------------------------------------------------ */
@@ -17,234 +17,332 @@ interface DateRangePickerProps {
/* ------------------------------------------------------------------ */
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
return new Date(year, month + 1, 0).getDate();
}
function firstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay(); // 0=Sun
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}`;
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}`;
if (!dateStr) return "";
const [y, m, d] = dateStr.split("-");
return `${y}.${m}.${d}`;
}
function isSame(a: string, b: string): boolean {
return a === b;
return a === b;
}
function isBetween(date: string, from: string, to: string): boolean {
return date >= from && date <= to;
return date >= from && date <= to;
}
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"];
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
const MONTH_NAMES = [
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월",
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
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);
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]);
// 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 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 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 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);
};
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 },
];
// 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)}`
: "기간 선택";
// 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);
}
// 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>
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>
{/* 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>
{/* 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>
{/* 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>
{/* 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" />;
{/* 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);
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";
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";
}
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>
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>
);
{/* 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>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
@@ -9,40 +9,43 @@ import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
interface RecentItem {
id: string;
time: string;
direction: "입고" | "출고";
type: string;
itemName: string;
qty: string;
partnerName: string;
statusColor: string;
statusLabel: string;
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;
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 || "대기" };
}
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 || "대기" };
}
}
/* ------------------------------------------------------------------ */
@@ -50,30 +53,50 @@ function getStatusStyle(status: string | null): { color: string; label: string }
/* ------------------------------------------------------------------ */
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: "#",
},
{
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: "#",
},
];
/* ------------------------------------------------------------------ */
@@ -81,205 +104,280 @@ const MENU_ITEMS = [
/* ------------------------------------------------------------------ */
export function InventoryHome() {
const router = useRouter();
const router = useRouter();
const [kpi, setKpi] = useState<KpiData>({ todayInbound: 0, todayOutbound: 0, todayTotal: 0 });
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
const [loading, setLoading] = useState(true);
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);
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 [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 ?? [];
const inRows: any[] = inRes.data?.data ?? [];
const outRows: any[] = outRes.data?.data ?? [];
setKpi({
todayInbound: inRows.length,
todayOutbound: outRows.length,
todayTotal: inRows.length + outRows.length,
});
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);
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);
}
};
setRecentItems(combined);
} catch {
// keep empty
} finally {
setLoading(false);
}
};
fetchData();
}, []);
fetchData();
}, []);
const handleMenuClick = (item: (typeof MENU_ITEMS)[number]) => {
if (item.href === "#") {
alert(`${item.title} 화면은 준비 중입니다.`);
} else {
router.push(item.href);
}
};
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>
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>
{/* 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>
{/* 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>
);
{/* 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>
);
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>
);
}
@@ -1,2 +1,2 @@
export { InventoryHome } from "./InventoryHome";
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
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
@@ -9,21 +9,21 @@ import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
interface RecentItem {
id: string;
itemName: string;
itemCode: string;
inspectionType: string;
judgment: string;
judgmentColor: string;
judgmentLabel: string;
time: string;
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;
todayTotal: number;
todayPass: number;
todayFail: number;
passRate: number;
}
/* ------------------------------------------------------------------ */
@@ -31,24 +31,36 @@ interface KpiData {
/* ------------------------------------------------------------------ */
function getJudgmentStyle(j: string): { color: string; label: string } {
if (j === "합격" || j === "pass") return { color: "text-green-600 bg-green-50", label: "합격" };
if (j === "불합격" || j === "fail") return { color: "text-red-600 bg-red-50", label: "합격" };
return { color: "text-amber-600 bg-amber-50", label: "대기" };
if (j === "합격" || j === "pass")
return { color: "text-green-600 bg-green-50", label: "합격" };
if (j === "불합격" || j === "fail")
return { color: "text-red-600 bg-red-50", label: "불합격" };
return { color: "text-amber-600 bg-amber-50", label: "대기" };
}
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",
},
{
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",
},
];
/* ------------------------------------------------------------------ */
@@ -56,164 +68,252 @@ const MENU_ITEMS = [
/* ------------------------------------------------------------------ */
export function QualityHome() {
const router = useRouter();
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);
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);
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 res = await apiClient.post(
"/table-management/tables/inspection_result_mng/data",
{
page: 1,
pageSize: 500,
},
);
const rows: any[] = res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
const todayRows = rows.filter((r: any) => (r.created_date || "").slice(0, 10) === today);
const rows: any[] =
res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
const todayRows = rows.filter(
(r: any) => (r.created_date || "").slice(0, 10) === today,
);
const total = todayRows.length;
const pass = todayRows.filter((r: any) => r.overall_judgment === "합격" || r.overall_judgment === "pass").length;
const fail = todayRows.filter((r: any) => r.overall_judgment === "불합격" || r.overall_judgment === "fail").length;
const passRate = total > 0 ? Math.round((pass / total) * 100) : 0;
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 });
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();
}, []);
// 최근 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>
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>
{/* 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>
{/* 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>
);
{/* 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>
);
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>
);
}
@@ -1,2 +1,2 @@
export { QualityHome } from "./QualityHome";
export { InspectionList } from "./InspectionList";
export { QualityHome } from "./QualityHome";
+341 -290
View File
@@ -23,362 +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;
updateItemRow: (rowKey: string, partialRow: Record<string, unknown>) => 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}:${JSON.stringify(item.row)}`)
.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,
),
);
},
[],
);
// 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,
),
);
},
[],
);
// row 객체에 임의 필드를 부분 업데이트 (예: inspectionResult)
const updateItemRow = useCallback(
(rowKey: string, partialRow: Record<string, unknown>) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey ? { ...i, row: { ...i.row, ...partialRow } } : i,
),
);
},
[],
);
const isItemInCart = useCallback(
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
[cartItems],
);
const isItemInCart = useCallback(
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
[cartItems],
);
const getCartItem = useCallback(
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
[cartItems],
);
const getCartItem = useCallback(
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
[cartItems],
);
// ----- diff 계산 (백엔드 전송용) -----
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
const currentScreenId = screenIdRef.current;
// ----- diff 계산 (백엔드 전송용) -----
const getChanges = useCallback(
(selectedColumns?: string[]): CartChanges => {
const currentScreenId = screenIdRef.current;
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
const toCreateItems = cartItems.filter((c) => !c.cartId);
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);
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;
});
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
);
});
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]);
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],
);
// ----- DB 저장 (일괄) -----
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving");
try {
const currentScreenId = screenIdRef.current;
// ----- DB 저장 (일괄) -----
const saveToDb = useCallback(
async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving");
try {
const currentScreenId = screenIdRef.current;
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDelete = savedItems.filter(
(s) => s.cartId && !cartRowKeys.has(s.rowKey),
);
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
const toCreate = cartItems.filter((c) => !c.cartId);
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
const toCreate = cartItems.filter((c) => !c.cartId);
// 수정 대상: 양쪽 다 존재하고 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
);
});
// 수정 대상: 양쪽 다 존재하고 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
);
});
const promises: Promise<unknown>[] = [];
const promises: Promise<unknown>[] = [];
for (const item of toDelete) {
promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
}
for (const item of toDelete) {
promises.push(
dataApi.updateRecord("cart_items", item.cartId!, {
status: "cancelled",
}),
);
}
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 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 toUpdate) {
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
}
for (const item of toUpdate) {
const record = cartItemToDbRecord(
item,
currentScreenId,
selectedColumns,
);
promises.push(
dataApi.updateRecord("cart_items", item.cartId!, record),
);
}
await Promise.all(promises);
await Promise.all(promises);
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
await loadFromDb();
return true;
} catch (err) {
console.error("[useCartSync] DB 저장 실패:", err);
setSyncStatus("dirty");
return false;
}
}, [cartItems, savedItems, loadFromDb]);
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
await loadFromDb();
return true;
} catch (err) {
console.error("[useCartSync] DB 저장 실패:", err);
setSyncStatus("dirty");
return false;
}
},
[cartItems, savedItems, loadFromDb],
);
// ----- 로컬 변경 취소 -----
const resetToSaved = useCallback(() => {
setCartItems(savedItems);
setSyncStatus("clean");
}, [savedItems]);
// ----- 로컬 변경 취소 -----
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,
};
return {
cartItems,
savedItems,
syncStatus,
cartCount: cartItems.length,
isDirty,
loading,
addItem,
removeItem,
updateItemQuantity,
updateItemRow,
isItemInCart,
getCartItem,
getChanges,
saveToDb,
loadFromDb,
resetToSaved,
};
}