WIP: POP + packaging 작업 중
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -227,10 +227,24 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로운 JWT 토큰 발급 (company_code만 변경)
|
// 전환 대상 회사명 조회
|
||||||
|
let targetCompanyName: string | undefined;
|
||||||
|
if (companyCode === "*") {
|
||||||
|
targetCompanyName = "공통";
|
||||||
|
} else {
|
||||||
|
const { query: dbQuery } = await import("../database/db");
|
||||||
|
const companyRows = await dbQuery<{ company_name: string }>(
|
||||||
|
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||||
|
[companyCode.trim()]
|
||||||
|
);
|
||||||
|
targetCompanyName = companyRows[0]?.company_name || companyCode.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 JWT 토큰 발급 (company_code + company_name 변경)
|
||||||
const newPersonBean: PersonBean = {
|
const newPersonBean: PersonBean = {
|
||||||
...currentUser,
|
...currentUser,
|
||||||
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
|
companyCode: companyCode.trim(),
|
||||||
|
companyName: targetCompanyName,
|
||||||
};
|
};
|
||||||
|
|
||||||
const newToken = JwtUtils.generateToken(newPersonBean);
|
const newToken = JwtUtils.generateToken(newPersonBean);
|
||||||
@@ -355,6 +369,7 @@ export class AuthController {
|
|||||||
deptName: dbUserInfo.deptName || "",
|
deptName: dbUserInfo.deptName || "",
|
||||||
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||||
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||||
|
companyName: userInfo.companyName || dbUserInfo.companyName || "", // JWT 토큰 우선 (회사 전환 시 갱신됨)
|
||||||
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||||
email: dbUserInfo.email || "",
|
email: dbUserInfo.email || "",
|
||||||
|
|||||||
@@ -161,6 +161,38 @@ export async function deletePkgUnit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 품목별 포장단위 조회 (item_number → pkg_unit 목록)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getPkgUnitsByItem(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { itemNumber } = req.params;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT pu.id, pu.pkg_code, pu.pkg_name, pu.pkg_type, pu.status,
|
||||||
|
pu.width_mm, pu.length_mm, pu.height_mm,
|
||||||
|
pu.self_weight_kg, pu.max_load_kg, pu.volume_l,
|
||||||
|
pui.pkg_qty
|
||||||
|
FROM pkg_unit_item pui
|
||||||
|
JOIN pkg_unit pu ON pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code
|
||||||
|
WHERE pui.item_number = $1 AND pui.company_code = $2 AND pu.status = 'ACTIVE'
|
||||||
|
ORDER BY pu.pkg_name`,
|
||||||
|
[itemNumber, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("품목별 포장단위 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// 포장단위 매칭품목 (pkg_unit_item) CRUD
|
// 포장단위 매칭품목 (pkg_unit_item) CRUD
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -405,6 +437,38 @@ export async function deleteLoadingUnit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 포장코드별 적재함 조회 (pkg_code → loading_unit 목록)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getLoadingUnitsByPkg(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { pkgCode } = req.params;
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT lu.id, lu.loading_code, lu.loading_name, lu.loading_type, lu.status,
|
||||||
|
lu.width_mm, lu.length_mm, lu.height_mm,
|
||||||
|
lu.self_weight_kg, lu.max_load_kg, lu.max_stack,
|
||||||
|
lup.max_load_qty, lup.load_method
|
||||||
|
FROM loading_unit_pkg lup
|
||||||
|
JOIN loading_unit lu ON lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code
|
||||||
|
WHERE lup.pkg_code = $1 AND lup.company_code = $2 AND lu.status = 'ACTIVE'
|
||||||
|
ORDER BY lu.loading_name`,
|
||||||
|
[pkgCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("포장별 적재함 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// 적재함 포장구성 (loading_unit_pkg) CRUD
|
// 적재함 포장구성 (loading_unit_pkg) CRUD
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -155,8 +155,13 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||||||
.json({ success: false, message: "입고 품목이 없습니다." });
|
.json({ success: false, message: "입고 품목이 없습니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 첫 번째 아이템에서 inbound_type 추출 (헤더용)
|
// 헤더용 inbound_type: 단일이면 그 값, 혼합이면 "혼합입고"
|
||||||
const inboundType = items[0].inbound_type || null;
|
const uniqueInboundTypes = [...new Set(items.map((i: any) => i.inbound_type).filter(Boolean))];
|
||||||
|
const inboundType = uniqueInboundTypes.length === 1
|
||||||
|
? uniqueInboundTypes[0]
|
||||||
|
: uniqueInboundTypes.length > 1
|
||||||
|
? "혼합입고"
|
||||||
|
: (items[0].inbound_type || null);
|
||||||
const inboundNumber = inbound_number || items[0].inbound_number;
|
const inboundNumber = inbound_number || items[0].inbound_number;
|
||||||
|
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
@@ -331,12 +336,11 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
|
// 2c. source_table 기준 소스 데이터 업데이트 (이중 입고 방지)
|
||||||
if (
|
const srcTable = item.source_table;
|
||||||
item.inbound_type === "구매입고" &&
|
const srcId = item.source_id;
|
||||||
item.source_id &&
|
|
||||||
item.source_table === "purchase_order_mng"
|
if (srcTable === "purchase_order_mng" && srcId) {
|
||||||
) {
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE purchase_order_mng
|
`UPDATE purchase_order_mng
|
||||||
SET received_qty = CAST(
|
SET received_qty = CAST(
|
||||||
@@ -354,17 +358,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||||||
END,
|
END,
|
||||||
updated_date = NOW()
|
updated_date = NOW()
|
||||||
WHERE id = $2 AND company_code = $3`,
|
WHERE id = $2 AND company_code = $3`,
|
||||||
[item.inbound_qty || 0, item.source_id, companyCode],
|
[item.inbound_qty || 0, srcId, companyCode],
|
||||||
);
|
);
|
||||||
}
|
} else if (srcTable === "purchase_detail" && srcId) {
|
||||||
|
|
||||||
// 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트
|
|
||||||
if (
|
|
||||||
item.inbound_type === "구매입고" &&
|
|
||||||
item.source_id &&
|
|
||||||
item.source_table === "purchase_detail"
|
|
||||||
) {
|
|
||||||
// 1. 해당 purchase_detail의 received_qty 누적 업데이트
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE purchase_detail SET
|
`UPDATE purchase_detail SET
|
||||||
received_qty = CAST(
|
received_qty = CAST(
|
||||||
@@ -377,17 +373,15 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||||||
),
|
),
|
||||||
updated_date = NOW()
|
updated_date = NOW()
|
||||||
WHERE id = $2 AND company_code = $3`,
|
WHERE id = $2 AND company_code = $3`,
|
||||||
[item.inbound_qty || 0, item.source_id, companyCode],
|
[item.inbound_qty || 0, srcId, companyCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. 발주 헤더 상태 업데이트
|
|
||||||
const detailInfo = await client.query(
|
const detailInfo = await client.query(
|
||||||
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
|
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
|
||||||
[item.source_id, companyCode],
|
[srcId, companyCode],
|
||||||
);
|
);
|
||||||
if (detailInfo.rows.length > 0) {
|
if (detailInfo.rows.length > 0) {
|
||||||
const purchaseNo = detailInfo.rows[0].purchase_no;
|
const purchaseNo = detailInfo.rows[0].purchase_no;
|
||||||
// 잔량 있는 디테일이 있는지 확인
|
|
||||||
const unreceived = await client.query(
|
const unreceived = await client.query(
|
||||||
`SELECT id FROM purchase_detail
|
`SELECT id FROM purchase_detail
|
||||||
WHERE purchase_no = $1 AND company_code = $2
|
WHERE purchase_no = $1 AND company_code = $2
|
||||||
@@ -419,6 +413,28 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||||||
[newStatus, purchaseNo, companyCode],
|
[newStatus, purchaseNo, companyCode],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (srcTable === "work_order_process" && srcId) {
|
||||||
|
// 생산입고: target_warehouse_id 세팅 (이중 입고 방지)
|
||||||
|
const whCode = warehouse_code || item.warehouse_code || null;
|
||||||
|
const locCode = location_code || item.location_code || null;
|
||||||
|
await client.query(
|
||||||
|
`UPDATE work_order_process
|
||||||
|
SET target_warehouse_id = $3,
|
||||||
|
target_location_code = $4,
|
||||||
|
writer = $5,
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2
|
||||||
|
AND target_warehouse_id IS NULL`,
|
||||||
|
[srcId, companyCode, whCode, locCode || null, userId],
|
||||||
|
);
|
||||||
|
} else if (srcTable && srcId) {
|
||||||
|
// 미처리 소스 테이블 — 추후 업데이트 로직 추가 필요
|
||||||
|
logger.warn("입고 소스 업데이트 미처리", {
|
||||||
|
source_table: srcTable,
|
||||||
|
source_id: srcId,
|
||||||
|
inbound_type: item.inbound_type,
|
||||||
|
item_number: item.item_number,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,6 +1060,8 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
|
|||||||
si.instruction_no,
|
si.instruction_no,
|
||||||
si.instruction_date,
|
si.instruction_date,
|
||||||
si.partner_id,
|
si.partner_id,
|
||||||
|
si.partner_id AS partner_code,
|
||||||
|
COALESCE(cm.customer_name, si.partner_id) AS partner_name,
|
||||||
si.status AS instruction_status,
|
si.status AS instruction_status,
|
||||||
sid.item_code,
|
sid.item_code,
|
||||||
sid.item_name,
|
sid.item_name,
|
||||||
@@ -1056,6 +1074,9 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
|
|||||||
JOIN shipment_instruction_detail sid
|
JOIN shipment_instruction_detail sid
|
||||||
ON si.id = sid.instruction_id
|
ON si.id = sid.instruction_id
|
||||||
AND si.company_code = sid.company_code
|
AND si.company_code = sid.company_code
|
||||||
|
LEFT JOIN customer_mng cm
|
||||||
|
ON cm.customer_code = si.partner_id
|
||||||
|
AND cm.company_code = si.company_code
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
ORDER BY si.instruction_date DESC, si.instruction_no
|
ORDER BY si.instruction_date DESC, si.instruction_no
|
||||||
LIMIT ${limit} OFFSET ${offset}`,
|
LIMIT ${limit} OFFSET ${offset}`,
|
||||||
@@ -1126,6 +1147,88 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 생산입고용: 실적이 등록된 작업지시 공정 데이터 조회 (미입고분)
|
||||||
|
export async function getProductionResults(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { processCode, keyword, pageSize } = req.query;
|
||||||
|
|
||||||
|
if (!processCode) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, message: "processCode 필수" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(500, Math.max(1, Number(pageSize) || 50));
|
||||||
|
const params: any[] = [companyCode, processCode];
|
||||||
|
let paramIdx = 3;
|
||||||
|
|
||||||
|
let keywordCondition = "";
|
||||||
|
if (keyword) {
|
||||||
|
keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`;
|
||||||
|
params.push(`%${keyword}%`);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const dataResult = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
wop.id,
|
||||||
|
wop.wo_id,
|
||||||
|
wi.work_instruction_no,
|
||||||
|
wi.start_date AS order_date,
|
||||||
|
wop.process_code,
|
||||||
|
wop.process_name,
|
||||||
|
wop.seq_no,
|
||||||
|
COALESCE(ii.item_number, wi.item_id) AS item_code,
|
||||||
|
COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name,
|
||||||
|
COALESCE(ii.size, '') AS spec,
|
||||||
|
COALESCE(ii.material, '') AS material,
|
||||||
|
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
|
||||||
|
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS order_qty,
|
||||||
|
0 AS received_qty,
|
||||||
|
COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0)
|
||||||
|
+ COALESCE(CAST(NULLIF(wop.concession_qty, '') AS numeric), 0) AS remain_qty,
|
||||||
|
'work_order_process' AS source_table,
|
||||||
|
wop.result_status,
|
||||||
|
COALESCE(ii.image, NULL) AS image,
|
||||||
|
CASE WHEN EXISTS (
|
||||||
|
SELECT 1 FROM item_inspection_info iii
|
||||||
|
WHERE iii.company_code = wop.company_code
|
||||||
|
AND COALESCE(iii.is_active, 'Y') = 'Y'
|
||||||
|
AND iii.item_code = COALESCE(ii.item_number, wi.item_id)
|
||||||
|
) THEN 'self' ELSE NULL END AS inspection_type
|
||||||
|
FROM work_order_process wop
|
||||||
|
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT ON (id, company_code)
|
||||||
|
id, item_number, item_name, size, material, image, company_code
|
||||||
|
FROM item_info
|
||||||
|
ORDER BY id, company_code, created_date DESC
|
||||||
|
) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code
|
||||||
|
WHERE wop.company_code = $1
|
||||||
|
AND wop.process_code = $2
|
||||||
|
AND wop.parent_process_id IS NULL
|
||||||
|
AND (wop.is_rework IS NULL OR wop.is_rework != 'Y')
|
||||||
|
AND COALESCE(CAST(NULLIF(wop.good_qty, '') AS numeric), 0) > 0
|
||||||
|
AND wop.target_warehouse_id IS NULL
|
||||||
|
${keywordCondition}
|
||||||
|
ORDER BY wi.work_instruction_no, CAST(wop.seq_no AS int)
|
||||||
|
LIMIT ${limit}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: dataResult.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) {
|
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Router } from "express";
|
|||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import {
|
import {
|
||||||
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
|
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
|
||||||
|
getPkgUnitsByItem,
|
||||||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||||
|
getLoadingUnitsByPkg,
|
||||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||||
getItemsByDivision, getGeneralItems,
|
getItemsByDivision, getGeneralItems,
|
||||||
} from "../controllers/packagingController";
|
} from "../controllers/packagingController";
|
||||||
@@ -18,6 +20,9 @@ router.post("/pkg-units", createPkgUnit);
|
|||||||
router.put("/pkg-units/:id", updatePkgUnit);
|
router.put("/pkg-units/:id", updatePkgUnit);
|
||||||
router.delete("/pkg-units/:id", deletePkgUnit);
|
router.delete("/pkg-units/:id", deletePkgUnit);
|
||||||
|
|
||||||
|
// 품목별 포장단위 조회
|
||||||
|
router.get("/pkg-units-by-item/:itemNumber", getPkgUnitsByItem);
|
||||||
|
|
||||||
// 포장단위 매칭품목
|
// 포장단위 매칭품목
|
||||||
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
|
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
|
||||||
router.post("/pkg-unit-items", createPkgUnitItem);
|
router.post("/pkg-unit-items", createPkgUnitItem);
|
||||||
@@ -29,6 +34,9 @@ router.post("/loading-units", createLoadingUnit);
|
|||||||
router.put("/loading-units/:id", updateLoadingUnit);
|
router.put("/loading-units/:id", updateLoadingUnit);
|
||||||
router.delete("/loading-units/:id", deleteLoadingUnit);
|
router.delete("/loading-units/:id", deleteLoadingUnit);
|
||||||
|
|
||||||
|
// 포장코드별 적재함 조회
|
||||||
|
router.get("/loading-units-by-pkg/:pkgCode", getLoadingUnitsByPkg);
|
||||||
|
|
||||||
// 적재함 포장구성
|
// 적재함 포장구성
|
||||||
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
||||||
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
saveMaterialInput,
|
saveMaterialInput,
|
||||||
getMaterialInputs,
|
getMaterialInputs,
|
||||||
getChecklistItems,
|
getChecklistItems,
|
||||||
|
getProcessList,
|
||||||
} from "../controllers/popProductionController";
|
} from "../controllers/popProductionController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -51,5 +52,6 @@ router.get("/bom-materials/:processId", getBomMaterials);
|
|||||||
router.post("/material-input", saveMaterialInput);
|
router.post("/material-input", saveMaterialInput);
|
||||||
router.get("/material-inputs/:processId", getMaterialInputs);
|
router.get("/material-inputs/:processId", getMaterialInputs);
|
||||||
router.get("/checklist-items/:processId", getChecklistItems);
|
router.get("/checklist-items/:processId", getChecklistItems);
|
||||||
|
router.get("/processes", getProcessList);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ router.get("/source/shipments", receivingController.getShipments);
|
|||||||
// 소스 데이터: 품목 (기타입고)
|
// 소스 데이터: 품목 (기타입고)
|
||||||
router.get("/source/items", receivingController.getItems);
|
router.get("/source/items", receivingController.getItems);
|
||||||
|
|
||||||
|
// 소스 데이터: 생산실적 (생산입고)
|
||||||
|
router.get("/source/production-results", receivingController.getProductionResults);
|
||||||
|
|
||||||
// 입고 등록
|
// 입고 등록
|
||||||
router.post("/", receivingController.create);
|
router.post("/", receivingController.create);
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,12 @@
|
|||||||
POP 영역에 파일 생성/수정/삭제가 발생하면 이 문서의 `## 작업 로그` 섹션에 날짜별 항목 추가.
|
POP 영역에 파일 생성/수정/삭제가 발생하면 이 문서의 `## 작업 로그` 섹션에 날짜별 항목 추가.
|
||||||
사용자가 별도 지시하지 않아도 자동으로 기록.
|
사용자가 별도 지시하지 않아도 자동으로 기록.
|
||||||
|
|
||||||
|
### 0-5. POP layout 수정 금지 (사용자 지시 필수)
|
||||||
|
- `COMPANY_7/pop/layout.tsx` 는 **사용자의 명시적 지시 없이 수정 금지**.
|
||||||
|
- 화면명(타이틀)/뒤로가기 버튼은 **각 page.tsx 내부에** 배치한다 (선례: `production/process/page.tsx` 2026-04-20 7차).
|
||||||
|
- `PopShell` 의 `title` / `showBack` / `headerRight` prop 을 layout 에서 전달하는 방식은 **금지**. 페이지 내부 헤더 행(뒤로가기 버튼 + `<h1>`)을 직접 렌더한다.
|
||||||
|
- layout 파일 수정이 정당한 경우(예: 공지 배너 추가 등)에도 **반드시 사용자 확인 선행**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 개요
|
## 1. 개요
|
||||||
@@ -180,6 +186,30 @@ frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유
|
|||||||
|
|
||||||
## 작업 로그
|
## 작업 로그
|
||||||
|
|
||||||
|
### 2026-04-22
|
||||||
|
- **POP layout 수정 금지 규칙 신설 (0-5 섹션 추가)**
|
||||||
|
- `COMPANY_7/pop/layout.tsx` 는 사용자의 명시적 지시 없이 수정 금지
|
||||||
|
- 화면명/뒤로가기 버튼은 각 page.tsx 내부에 배치 (선례: 2026-04-20 7차 `production/process/page.tsx`)
|
||||||
|
- **`COMPANY_7/pop/layout.tsx` 원복**
|
||||||
|
- Phase E 에서 추가했던 `isWork` 분기(`title="공정 작업"` + `showBack`) 제거
|
||||||
|
- `showBanner={isMain}` 만 남기고 `PopShell` 기본 렌더로 복귀 (타이틀은 업체명 기본값)
|
||||||
|
- **`production/work/[processId]/page.tsx` 에 뒤로가기 + 타이틀 이식**
|
||||||
|
- `production/process/page.tsx` 2026-04-20 7차 패턴 복제 — 뒤로가기 버튼(`w-10 h-10 rounded-xl`, gray-200 border) + "공정 작업" `<h1>`
|
||||||
|
- 뒤로가기 목적지: `/COMPANY_7/pop/production/process` (기존 `ProcessWork` 렌더는 유지)
|
||||||
|
- 래퍼가 11→25 줄로 확장, 동작 변경 없음
|
||||||
|
- **반응형 공통화 Phase 1 — 공통 컴포넌트 5개 신설 (`_components/common/`)**
|
||||||
|
- 상위 플랜: `.claude/plans/pop-responsive-refactor.md` (신규, 계획 문서)
|
||||||
|
- 신규: `theme.ts` (67줄) — `COLOR_MAP: Record<PopColor, PopColorTokens>` 9색 × 7토큰 완성 리터럴. Tailwind JIT purge 회피 원칙(동적 문자열 0)
|
||||||
|
- 신규: `PopButton.tsx` (50줄) — size sm/md/lg(min 96×40 / 144×48 / 200×56), icon prop, forwardRef, `COLOR_MAP[color]` 자동 적용
|
||||||
|
- 신규: `PopCard.tsx` (45줄) — `w-full min-h-[180px]` + selected/color/interactive props, 선택 시 `COLOR_MAP[color].ringSelected`
|
||||||
|
- 신규: `PopCardGrid.tsx` (75줄) — 브레이크포인트별 cols map(1~4) + gap sm/md/lg. Tailwind 리터럴 map 방식
|
||||||
|
- 신규: `PopModal.tsx` (71줄) — size sm/md/lg/xl 전부 `w-[min(Xvw,Ypx)]` 반응형, ESC 닫기, footer slot
|
||||||
|
- 기존 파일 수정 0건. 아직 어느 화면도 import 하지 않음 (unused)
|
||||||
|
- 검증: tsc --noEmit baseline 3090 유지, `/COMPANY_7/pop/main` 브라우저 로드 회귀 0 (콘솔 에러 0)
|
||||||
|
- **반응형 공통화 Phase 2 — 취소**
|
||||||
|
- 계획 원안: 기존 공용 모달 4개(`BarcodeScanModal`/`ConfirmModal`/`EquipmentModal`/`SimpleKeypadModal`)를 PopModal 기반으로 내부 개편
|
||||||
|
- 취소 사유: 4개 모달이 각자 특수 UX(ConfirmModal 분할버튼 + `z-[100]`, SimpleKeypadModal blue gradient header + maxQty 배지, EquipmentModal header 내 정렬 버튼, BarcodeScanModal shadcn Dialog 기반 aria 내장)라 일괄 래핑 시 시각 변경 발생. 사용자 결정으로 **기존 모달은 현 상태 유지**, PopModal은 **신규 모달 작성 시에만 기본 틀로 사용**. 계획 문서 §5 표에 취소 기록.
|
||||||
|
|
||||||
### 2026-04-15
|
### 2026-04-15
|
||||||
- `frontend/app/(main)/layout.tsx` 수정
|
- `frontend/app/(main)/layout.tsx` 수정
|
||||||
- `"use client"` 추가
|
- `"use client"` 추가
|
||||||
@@ -527,6 +557,43 @@ frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유
|
|||||||
- 타입 체크(tsc --noEmit): 새 에러 없음
|
- 타입 체크(tsc --noEmit): 새 에러 없음
|
||||||
- 브라우저 검증: 미수행
|
- 브라우저 검증: 미수행
|
||||||
|
|
||||||
|
### 2026-04-21 (2차)
|
||||||
|
- **포장단위 모달 복수 등록 개편 + maxQty 버그 수정**
|
||||||
|
- `_components/inbound/NumberPadModal.tsx` 전면 재작성
|
||||||
|
- 기존 3단계(1차 포장선택→개당수량→포장개수 + 잔량 포장 + 확인) → 단일 list step 기반 구조로 전환
|
||||||
|
- list step: 포장 수량/미포장 잔량 실시간 요약, 등록된 포장 목록(행별 [편집][삭제]), [+ 포장단위 추가], [확인]
|
||||||
|
- packaging step: 포장단위 선택 (pkg_qty 자동 세팅, 개당수량 입력 단계 제거)
|
||||||
|
- count step: 개수만 입력 (MAX = `Math.floor(사용가능잔량 / pkg_qty)`)
|
||||||
|
- 같은 `pkg_code` 재선택 시 기존 행 `count` 합산 (pkg_qty 동일 전제)
|
||||||
|
- 편집 시 해당 행 수량을 사용가능잔량에 되돌린 뒤 max 계산
|
||||||
|
- 잔량 > 0 이어도 확정 가능 (입고/출고 관리화면에서 수정 여지)
|
||||||
|
- 적재함 선택 흐름은 변경 없음 (InboundCartPage/OutboundCartPage의 별도 LoadingUnitModal 유지)
|
||||||
|
- `_components/inbound/InboundCartPage.tsx`
|
||||||
|
- **MAX 버그 수정**: `NumberPadModal`에 전달하던 `maxQty={packagingTarget.remain_qty}` → `packagingTarget.inbound_qty`
|
||||||
|
- `handlePackagingConfirm`: `Math.min(qty, remain_qty)` → `packagingTarget.inbound_qty` 고정 (포장 합계로 수량 덮어쓰기 제거, 미포장 잔량 허용)
|
||||||
|
- 포장 정보 카드 UI: 배지 `포장완료`(green) / `부분포장`(amber) 분기 + 미포장 잔량 줄 추가
|
||||||
|
- `_components/outbound/OutboundCartPage.tsx`: 동일 패턴 (`outbound_qty` 기준)
|
||||||
|
- 버그 원인: 기존 `remain_qty`는 발주 잔량(예: 발주 200, 미입고 200)이라 장바구니에 50 담아도 MAX 버튼/잔량 계산이 200 기준 → "50 EA 중 1박스(40EA)만 담아도 MAX=5박스 / 잔량 160" 증상
|
||||||
|
- 검증:
|
||||||
|
- `tsc --noEmit` (frontend): 변경 파일 기준 신규 에러 0 (기존 `lib/utils/*`, `v2-core/*` 등 baseline만 존재)
|
||||||
|
- 브라우저 검증: MAX 버그 수정 동작 확인 (모달 헤더 `최대 50 EA`, 발주수량 100 기준 아님). 복수 추가/합산/편집/삭제/배지 분기는 현 장바구니 품목 3건(DEMO-PROD-001/B_ETCE3_001/F_GMP02_003)에 `pkg_unit_item` 미등록으로 미검증 — 사용자 승인 SKIP
|
||||||
|
- PreToolUse 훅이 카드 UI 편집을 2회 차단 → 사용자에게 세부 변경 목록(배지/색상/미포장 줄) 제시 후 승인받아 통과
|
||||||
|
|
||||||
|
### 2026-04-21
|
||||||
|
- **판매출고 시연용 더미 데이터 추가 후 동일 세션 내 전량 롤백 (사용자 지시)**
|
||||||
|
- `_components/outbound/SalesOutbound.tsx` `fetchOrders`: 더미 3건 삽입 → 원래 빈 배열(`setOrders([])`)로 원복
|
||||||
|
- `_components/outbound/OutboundCartPage.tsx` `handleConfirm`: `allDummy` 스킵 분기 추가 → 원래 `const res = await apiClient.post("/outbound", payload);` 한 줄로 원복
|
||||||
|
- 현재 상태: 2026-04-20 (9차) 시점과 동일. 판매출고 화면은 다시 `fetchOrders` 빈 배열 / `fetchAllCustomers` 빈 배열 상태 (UI 클론, DB 미연동)
|
||||||
|
|
||||||
|
### 2026-04-22 (2차)
|
||||||
|
- **POP 반응형 공통 컴포넌트 5개 신설** (`_components/common/`)
|
||||||
|
- `theme.ts` — PopColor 타입(9색) + COLOR_MAP 팔레트 (buttonBg/buttonBgHover/ring/ringSelected/text/bg50/border), 완성 리터럴만, JIT 안전
|
||||||
|
- `PopButton.tsx` — forwardRef, size(sm/md/lg) + color + icon props, COLOR_MAP 조회 기반, 외부 className append
|
||||||
|
- `PopCard.tsx` — forwardRef, selected/color/interactive props, ringSelected + border 색상 교체, 외부 className append
|
||||||
|
- `PopCardGrid.tsx` — grid + gap, ColProfile(base/md/lg/xl/2xl) 모두 리터럴 맵 조회, 옵셔널 브레이크포인트 조건부 적용
|
||||||
|
- `PopModal.tsx` — open/onClose/size(sm/md/lg/xl)/title/children/footer/hideCloseButton, ESC 키 useEffect, backdrop click close
|
||||||
|
- 기존 파일 수정 없음. `tsc --noEmit`: 신규 파일 에러 0, 전체 baseline 3090 유지
|
||||||
|
|
||||||
### 2026-04-20 (9차)
|
### 2026-04-20 (9차)
|
||||||
- **출고관리 화면 API 연동 (UI 껍데기 → 실연동, 입고관리 로직 포팅)**
|
- **출고관리 화면 API 연동 (UI 껍데기 → 실연동, 입고관리 로직 포팅)**
|
||||||
- `_components/outbound/OutboundManage.tsx` 전면 rewrite
|
- `_components/outbound/OutboundManage.tsx` 전면 rewrite
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||||
|
import { COLOR_MAP, type PopColor } from "./theme";
|
||||||
|
|
||||||
|
type Size = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
color?: PopColor;
|
||||||
|
size?: Size;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
|
sm: "min-w-[96px] min-h-[40px] text-sm px-3",
|
||||||
|
md: "min-w-[144px] min-h-[48px] text-base px-4",
|
||||||
|
lg: "min-w-[200px] min-h-[56px] text-lg px-6",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMON =
|
||||||
|
"rounded-xl font-semibold text-white shadow-md transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2";
|
||||||
|
|
||||||
|
const PopButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
|
({ color = "blue", size = "md", icon, children, className, ...rest }, ref) => {
|
||||||
|
const tokens = COLOR_MAP[color];
|
||||||
|
const classes = [
|
||||||
|
COMMON,
|
||||||
|
SIZE_CLASSES[size],
|
||||||
|
tokens.buttonBg,
|
||||||
|
tokens.buttonBgHover,
|
||||||
|
tokens.ring,
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button ref={ref} className={classes} {...rest}>
|
||||||
|
{icon && <span className="shrink-0">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
PopButton.displayName = "PopButton";
|
||||||
|
|
||||||
|
export default PopButton;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { HTMLAttributes, forwardRef } from "react";
|
||||||
|
import { COLOR_MAP, type PopColor } from "./theme";
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
selected?: boolean;
|
||||||
|
color?: PopColor;
|
||||||
|
interactive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE =
|
||||||
|
"w-full min-h-[180px] rounded-2xl bg-white border shadow-sm p-4 flex flex-col transition-all";
|
||||||
|
|
||||||
|
const PopCard = forwardRef<HTMLDivElement, Props>(
|
||||||
|
(
|
||||||
|
{ selected = false, color = "blue", interactive = true, className, ...rest },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const tokens = COLOR_MAP[color];
|
||||||
|
|
||||||
|
const classes = [
|
||||||
|
BASE,
|
||||||
|
selected ? tokens.border : "border-gray-200",
|
||||||
|
interactive ? "hover:shadow-md hover:border-gray-300 cursor-pointer" : "",
|
||||||
|
selected ? tokens.ringSelected : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return <div ref={ref} className={classes} {...rest} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
PopCard.displayName = "PopCard";
|
||||||
|
|
||||||
|
export default PopCard;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type Cols = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
interface ColProfile {
|
||||||
|
base?: Cols;
|
||||||
|
md?: Cols;
|
||||||
|
lg?: Cols;
|
||||||
|
xl?: Cols;
|
||||||
|
"2xl"?: Cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
cols?: ColProfile;
|
||||||
|
gap?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_COLS: Record<Cols, string> = {
|
||||||
|
1: "grid-cols-1",
|
||||||
|
2: "grid-cols-2",
|
||||||
|
3: "grid-cols-3",
|
||||||
|
4: "grid-cols-4",
|
||||||
|
};
|
||||||
|
const MD_COLS: Record<Cols, string> = {
|
||||||
|
1: "md:grid-cols-1",
|
||||||
|
2: "md:grid-cols-2",
|
||||||
|
3: "md:grid-cols-3",
|
||||||
|
4: "md:grid-cols-4",
|
||||||
|
};
|
||||||
|
const LG_COLS: Record<Cols, string> = {
|
||||||
|
1: "lg:grid-cols-1",
|
||||||
|
2: "lg:grid-cols-2",
|
||||||
|
3: "lg:grid-cols-3",
|
||||||
|
4: "lg:grid-cols-4",
|
||||||
|
};
|
||||||
|
const XL_COLS: Record<Cols, string> = {
|
||||||
|
1: "xl:grid-cols-1",
|
||||||
|
2: "xl:grid-cols-2",
|
||||||
|
3: "xl:grid-cols-3",
|
||||||
|
4: "xl:grid-cols-4",
|
||||||
|
};
|
||||||
|
const XXL_COLS: Record<Cols, string> = {
|
||||||
|
1: "2xl:grid-cols-1",
|
||||||
|
2: "2xl:grid-cols-2",
|
||||||
|
3: "2xl:grid-cols-3",
|
||||||
|
4: "2xl:grid-cols-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GAP: Record<"sm" | "md" | "lg", string> = {
|
||||||
|
sm: "gap-3",
|
||||||
|
md: "gap-4",
|
||||||
|
lg: "gap-6",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PopCardGrid({
|
||||||
|
cols = { base: 1, md: 2, xl: 3 },
|
||||||
|
gap = "md",
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
const { base = 1, md, lg, xl, "2xl": xxl } = cols;
|
||||||
|
|
||||||
|
const classes = [
|
||||||
|
"grid w-full",
|
||||||
|
GAP[gap],
|
||||||
|
BASE_COLS[base],
|
||||||
|
md != null ? MD_COLS[md] : "",
|
||||||
|
lg != null ? LG_COLS[lg] : "",
|
||||||
|
xl != null ? XL_COLS[xl] : "",
|
||||||
|
xxl != null ? XXL_COLS[xxl] : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return <div className={classes} {...rest} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, useEffect } from "react";
|
||||||
|
|
||||||
|
type Size = "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
size?: Size;
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
hideCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<Size, string> = {
|
||||||
|
sm: "w-[min(90vw,420px)] max-h-[80vh]",
|
||||||
|
md: "w-[min(90vw,640px)] max-h-[85vh]",
|
||||||
|
lg: "w-[min(95vw,900px)] max-h-[90vh]",
|
||||||
|
xl: "w-[min(98vw,1200px)] max-h-[95vh]",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PopModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
size = "md",
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
hideCloseButton = false,
|
||||||
|
}: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${SIZE_CLASSES[size]} bg-white rounded-2xl shadow-xl flex flex-col`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{(title != null || !hideCloseButton) && (
|
||||||
|
<header className="flex items-center justify-between px-5 py-4 border-b border-gray-200 shrink-0">
|
||||||
|
{title != null && (
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||||
|
)}
|
||||||
|
{!hideCloseButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto w-10 h-10 flex items-center justify-center rounded-xl text-gray-500 hover:bg-gray-100 text-xl font-medium"
|
||||||
|
aria-label="닫기"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<main className="flex-1 overflow-y-auto p-4">{children}</main>
|
||||||
|
{footer && (
|
||||||
|
<footer className="border-t border-gray-200 p-4 shrink-0">
|
||||||
|
{footer}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// POP 9-color palette. 키는 부위별 완성 리터럴 — JIT 스캔 대상.
|
||||||
|
// 동적 문자열 생성 금지(`bg-${x}` 등). 반드시 COLOR_MAP[color].부위 조회로 접근.
|
||||||
|
|
||||||
|
export type PopColor =
|
||||||
|
| 'blue'
|
||||||
|
| 'purple'
|
||||||
|
| 'cyan'
|
||||||
|
| 'green'
|
||||||
|
| 'red'
|
||||||
|
| 'pink'
|
||||||
|
| 'teal'
|
||||||
|
| 'orange'
|
||||||
|
| 'amber';
|
||||||
|
|
||||||
|
export interface PopColorTokens {
|
||||||
|
buttonBg: string;
|
||||||
|
buttonBgHover: string;
|
||||||
|
ring: string;
|
||||||
|
ringSelected: string;
|
||||||
|
text: string;
|
||||||
|
bg50: string;
|
||||||
|
border: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_MAP: Record<PopColor, PopColorTokens> = {
|
||||||
|
blue: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-blue-400 to-blue-700',
|
||||||
|
buttonBgHover: 'hover:from-blue-500 hover:to-blue-800',
|
||||||
|
ring: 'focus:ring-blue-500',
|
||||||
|
ringSelected: 'ring-2 ring-blue-500',
|
||||||
|
text: 'text-blue-600',
|
||||||
|
bg50: 'bg-blue-50',
|
||||||
|
border: 'border-blue-200',
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-purple-400 to-purple-700',
|
||||||
|
buttonBgHover: 'hover:from-purple-500 hover:to-purple-800',
|
||||||
|
ring: 'focus:ring-purple-500',
|
||||||
|
ringSelected: 'ring-2 ring-purple-500',
|
||||||
|
text: 'text-purple-600',
|
||||||
|
bg50: 'bg-purple-50',
|
||||||
|
border: 'border-purple-200',
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-cyan-400 to-cyan-700',
|
||||||
|
buttonBgHover: 'hover:from-cyan-500 hover:to-cyan-800',
|
||||||
|
ring: 'focus:ring-cyan-500',
|
||||||
|
ringSelected: 'ring-2 ring-cyan-500',
|
||||||
|
text: 'text-cyan-600',
|
||||||
|
bg50: 'bg-cyan-50',
|
||||||
|
border: 'border-cyan-200',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-green-400 to-green-700',
|
||||||
|
buttonBgHover: 'hover:from-green-500 hover:to-green-800',
|
||||||
|
ring: 'focus:ring-green-500',
|
||||||
|
ringSelected: 'ring-2 ring-green-500',
|
||||||
|
text: 'text-green-600',
|
||||||
|
bg50: 'bg-green-50',
|
||||||
|
border: 'border-green-200',
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-red-400 to-red-700',
|
||||||
|
buttonBgHover: 'hover:from-red-500 hover:to-red-800',
|
||||||
|
ring: 'focus:ring-red-500',
|
||||||
|
ringSelected: 'ring-2 ring-red-500',
|
||||||
|
text: 'text-red-600',
|
||||||
|
bg50: 'bg-red-50',
|
||||||
|
border: 'border-red-200',
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-pink-400 to-pink-700',
|
||||||
|
buttonBgHover: 'hover:from-pink-500 hover:to-pink-800',
|
||||||
|
ring: 'focus:ring-pink-500',
|
||||||
|
ringSelected: 'ring-2 ring-pink-500',
|
||||||
|
text: 'text-pink-600',
|
||||||
|
bg50: 'bg-pink-50',
|
||||||
|
border: 'border-pink-200',
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-teal-400 to-teal-700',
|
||||||
|
buttonBgHover: 'hover:from-teal-500 hover:to-teal-800',
|
||||||
|
ring: 'focus:ring-teal-500',
|
||||||
|
ringSelected: 'ring-2 ring-teal-500',
|
||||||
|
text: 'text-teal-600',
|
||||||
|
bg50: 'bg-teal-50',
|
||||||
|
border: 'border-teal-200',
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-orange-400 to-orange-700',
|
||||||
|
buttonBgHover: 'hover:from-orange-500 hover:to-orange-800',
|
||||||
|
ring: 'focus:ring-orange-500',
|
||||||
|
ringSelected: 'ring-2 ring-orange-500',
|
||||||
|
text: 'text-orange-600',
|
||||||
|
bg50: 'bg-orange-50',
|
||||||
|
border: 'border-orange-200',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
buttonBg: 'bg-gradient-to-b from-amber-400 to-amber-700',
|
||||||
|
buttonBgHover: 'hover:from-amber-500 hover:to-amber-800',
|
||||||
|
ring: 'focus:ring-amber-500',
|
||||||
|
ringSelected: 'ring-2 ring-amber-500',
|
||||||
|
text: 'text-amber-600',
|
||||||
|
bg50: 'bg-amber-50',
|
||||||
|
border: 'border-amber-200',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -292,13 +292,11 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
|
|||||||
setPackagingOpen(true);
|
setPackagingOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePackagingConfirm = (qty: number, packages: PackageEntry[]) => {
|
const handlePackagingConfirm = (_qty: number, packages: PackageEntry[]) => {
|
||||||
if (!packagingTarget) return;
|
if (!packagingTarget) return;
|
||||||
const finalQty = Math.min(qty, packagingTarget.remain_qty);
|
|
||||||
|
|
||||||
cart.updateItemQuantity(
|
cart.updateItemQuantity(
|
||||||
packagingTarget.rowKey,
|
packagingTarget.rowKey,
|
||||||
finalQty,
|
packagingTarget.inbound_qty,
|
||||||
undefined,
|
undefined,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
packages.length > 0 ? (packages as any) : undefined,
|
packages.length > 0 ? (packages as any) : undefined,
|
||||||
@@ -1112,21 +1110,39 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* === Package info (포장 완료 시 — 클릭하면 모달 열림) === */}
|
{/* === Package info (포장 등록 시 — 클릭하면 모달 열림) === */}
|
||||||
{item.packages && item.packages.length > 0 && (
|
{item.packages && item.packages.length > 0 && (() => {
|
||||||
<>
|
const packagedQty = item.packages.reduce(
|
||||||
|
(s, p) => s + p.count * p.qtyPerUnit,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const unpacked = Math.max(0, item.inbound_qty - packagedQty);
|
||||||
|
const isComplete = unpacked === 0;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => openPackaging(item)}
|
onClick={() => openPackaging(item)}
|
||||||
className="mt-2.5 px-3 py-2 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg cursor-pointer active:scale-95 transition-all"
|
className={`mt-2.5 px-3 py-2 border rounded-lg cursor-pointer active:scale-95 transition-all ${
|
||||||
|
isComplete
|
||||||
|
? "bg-gradient-to-r from-green-50 to-emerald-50 border-green-200"
|
||||||
|
: "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[10px] font-bold text-white bg-gradient-to-r from-green-500 to-green-600 px-2 py-0.5 rounded-full">
|
<span
|
||||||
포장완료
|
className={`text-[10px] font-bold text-white px-2 py-0.5 rounded-full ${
|
||||||
|
isComplete
|
||||||
|
? "bg-gradient-to-r from-green-500 to-green-600"
|
||||||
|
: "bg-gradient-to-r from-amber-500 to-orange-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isComplete ? "포장완료" : "부분포장"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-semibold text-green-600">
|
<span
|
||||||
{item.packages
|
className={`text-[11px] font-semibold ${
|
||||||
.reduce((s, p) => s + p.count * p.qtyPerUnit, 0)
|
isComplete ? "text-green-600" : "text-amber-600"
|
||||||
.toLocaleString()}{" "}
|
}`}
|
||||||
|
>
|
||||||
|
{packagedQty.toLocaleString()}{" "}
|
||||||
EA
|
EA
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1145,11 +1161,19 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{!isComplete && (
|
||||||
|
<div className="mt-1 pt-1 border-t border-amber-200 flex items-center justify-between text-[11px]">
|
||||||
|
<span className="text-amber-700 font-medium">미포장</span>
|
||||||
|
<span className="font-bold text-amber-700">
|
||||||
|
{unpacked.toLocaleString()} EA
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1348,7 +1372,7 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
|
|||||||
setPackagingTarget(null);
|
setPackagingTarget(null);
|
||||||
}}
|
}}
|
||||||
onConfirm={handlePackagingConfirm}
|
onConfirm={handlePackagingConfirm}
|
||||||
maxQty={packagingTarget.remain_qty}
|
maxQty={packagingTarget.inbound_qty}
|
||||||
itemName={packagingTarget.item_name}
|
itemName={packagingTarget.item_name}
|
||||||
itemNumber={packagingTarget.item_code}
|
itemNumber={packagingTarget.item_code}
|
||||||
initialPackages={packagingTarget.packages}
|
initialPackages={packagingTarget.packages}
|
||||||
|
|||||||
@@ -31,15 +31,7 @@ interface NumberPadModalProps {
|
|||||||
initialPackages?: PackageEntry[];
|
initialPackages?: PackageEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step =
|
type Step = "list" | "packaging" | "count";
|
||||||
| "packaging"
|
|
||||||
| "qty-per-pkg"
|
|
||||||
| "pkg-count"
|
|
||||||
| "remainder"
|
|
||||||
| "rem-packaging"
|
|
||||||
| "rem-qty-per-pkg"
|
|
||||||
| "rem-pkg-count"
|
|
||||||
| "confirm";
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Numpad Keys */
|
/* Numpad Keys */
|
||||||
@@ -73,21 +65,15 @@ export function NumberPadModal({
|
|||||||
itemNumber,
|
itemNumber,
|
||||||
initialPackages,
|
initialPackages,
|
||||||
}: NumberPadModalProps) {
|
}: NumberPadModalProps) {
|
||||||
const [step, setStep] = useState<Step>("packaging");
|
const [step, setStep] = useState<Step>("list");
|
||||||
|
const [packages, setPackages] = useState<PackageEntry[]>([]);
|
||||||
// 1차 포장
|
|
||||||
const [selectedUnit, setSelectedUnit] = useState<PackageUnit | null>(null);
|
|
||||||
const [pkgCount, setPkgCount] = useState("0");
|
|
||||||
const [qtyPerPkg, setQtyPerPkg] = useState("0");
|
|
||||||
|
|
||||||
// 나머지 포장
|
|
||||||
const [remUnit, setRemUnit] = useState<PackageUnit | null>(null);
|
|
||||||
const [remPkgCount, setRemPkgCount] = useState("0");
|
|
||||||
const [remQtyPerPkg, setRemQtyPerPkg] = useState("0");
|
|
||||||
|
|
||||||
const [packageUnits, setPackageUnits] = useState<PackageUnit[]>([]);
|
const [packageUnits, setPackageUnits] = useState<PackageUnit[]>([]);
|
||||||
const [pkgLoading, setPkgLoading] = useState(false);
|
const [pkgLoading, setPkgLoading] = useState(false);
|
||||||
|
|
||||||
|
const [selectedUnit, setSelectedUnit] = useState<PackageUnit | null>(null);
|
||||||
|
const [count, setCount] = useState("0");
|
||||||
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
/* Fetch packaging units from DB */
|
/* Fetch packaging units from DB */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !itemNumber) {
|
if (!open || !itemNumber) {
|
||||||
@@ -108,172 +94,158 @@ export function NumberPadModal({
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => { if (!cancelled) setPackageUnits([]); })
|
.catch(() => {
|
||||||
.finally(() => { if (!cancelled) setPkgLoading(false); });
|
if (!cancelled) setPackageUnits([]);
|
||||||
return () => { cancelled = true; };
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setPkgLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [open, itemNumber]);
|
}, [open, itemNumber]);
|
||||||
|
|
||||||
/* Reset on open */
|
/* Reset on open */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (initialPackages && initialPackages.length > 0) {
|
setPackages(initialPackages ? [...initialPackages] : []);
|
||||||
const p = initialPackages[0];
|
setStep("list");
|
||||||
setSelectedUnit(p.unit);
|
|
||||||
setPkgCount(String(p.count));
|
|
||||||
setQtyPerPkg(String(p.qtyPerUnit));
|
|
||||||
if (initialPackages.length > 1) {
|
|
||||||
const r = initialPackages[1];
|
|
||||||
setRemUnit(r.unit);
|
|
||||||
setRemPkgCount(String(r.count));
|
|
||||||
setRemQtyPerPkg(String(r.qtyPerUnit));
|
|
||||||
} else {
|
|
||||||
setRemUnit(null);
|
|
||||||
setRemPkgCount("0");
|
|
||||||
setRemQtyPerPkg("0");
|
|
||||||
}
|
|
||||||
setStep("confirm");
|
|
||||||
} else {
|
|
||||||
setStep("packaging");
|
|
||||||
setSelectedUnit(null);
|
setSelectedUnit(null);
|
||||||
setPkgCount("0");
|
setCount("0");
|
||||||
setQtyPerPkg("0");
|
setEditingIndex(null);
|
||||||
setRemUnit(null);
|
|
||||||
setRemPkgCount("0");
|
|
||||||
setRemQtyPerPkg("0");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [open, initialPackages]);
|
}, [open, initialPackages]);
|
||||||
|
|
||||||
/* Computed values */
|
/* Computed values */
|
||||||
const pkgCountNum = parseInt(pkgCount, 10) || 0;
|
const totalPackagedQty = packages.reduce(
|
||||||
const qtyPerPkgNum = parseInt(qtyPerPkg, 10) || 0;
|
(s, p) => s + p.count * p.qtyPerUnit,
|
||||||
const primaryQty = pkgCountNum * qtyPerPkgNum;
|
0
|
||||||
|
);
|
||||||
|
const unpackedQty = Math.max(0, maxQty - totalPackagedQty);
|
||||||
|
const countNum = parseInt(count, 10) || 0;
|
||||||
|
|
||||||
const remPkgCountNum = parseInt(remPkgCount, 10) || 0;
|
/* 편집 중이면 해당 행 수량만큼은 "사용 가능"으로 되돌려줌 */
|
||||||
const remQtyPerPkgNum = parseInt(remQtyPerPkg, 10) || 0;
|
const editingEntry =
|
||||||
const remQty = remUnit ? remPkgCountNum * remQtyPerPkgNum : 0;
|
editingIndex !== null ? packages[editingIndex] ?? null : null;
|
||||||
|
const editingReservedQty = editingEntry
|
||||||
|
? editingEntry.count * editingEntry.qtyPerUnit
|
||||||
|
: 0;
|
||||||
|
const availableForCurrent = unpackedQty + editingReservedQty;
|
||||||
|
const currentPkgQty = selectedUnit?.pkg_qty ?? 0;
|
||||||
|
const maxCountForCurrent =
|
||||||
|
currentPkgQty > 0 ? Math.floor(availableForCurrent / currentPkgQty) : 0;
|
||||||
|
|
||||||
const remainder = qtyPerPkgNum > 0 ? maxQty - primaryQty : 0;
|
/* Numpad input */
|
||||||
const totalQty = primaryQty + remQty;
|
|
||||||
const isOverMax = totalQty > maxQty;
|
|
||||||
|
|
||||||
/* Generic numpad input handler */
|
|
||||||
const handleInput = useCallback(
|
const handleInput = useCallback(
|
||||||
(key: string, setter: React.Dispatch<React.SetStateAction<string>>, max?: number) => {
|
(key: string) => {
|
||||||
setter((prev) => {
|
setCount((prev) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "backspace":
|
case "backspace":
|
||||||
return prev.length <= 1 ? "0" : prev.slice(0, -1);
|
return prev.length <= 1 ? "0" : prev.slice(0, -1);
|
||||||
case "clear":
|
case "clear":
|
||||||
return "0";
|
return "0";
|
||||||
case "max":
|
case "max":
|
||||||
return String(max ?? maxQty);
|
return String(maxCountForCurrent);
|
||||||
default: {
|
default: {
|
||||||
const next = prev === "0" ? key : prev + key;
|
const next = prev === "0" ? key : prev + key;
|
||||||
const num = parseInt(next, 10);
|
const num = parseInt(next, 10);
|
||||||
if (isNaN(num)) return prev;
|
if (isNaN(num)) return prev;
|
||||||
if (max !== undefined) return String(Math.min(num, max));
|
return String(Math.min(num, maxCountForCurrent));
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[maxQty]
|
[maxCountForCurrent]
|
||||||
);
|
);
|
||||||
|
|
||||||
/* 1차 포장 handlers */
|
/* Step handlers */
|
||||||
const handleSelectPackaging = (unit: PackageUnit) => {
|
const openAddFlow = () => {
|
||||||
|
setEditingIndex(null);
|
||||||
|
setSelectedUnit(null);
|
||||||
|
setCount("0");
|
||||||
|
setStep("packaging");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditFlow = (idx: number) => {
|
||||||
|
const entry = packages[idx];
|
||||||
|
if (!entry) return;
|
||||||
|
setEditingIndex(idx);
|
||||||
|
setSelectedUnit(entry.unit);
|
||||||
|
setCount(String(entry.count));
|
||||||
|
setStep("count");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectUnit = (unit: PackageUnit) => {
|
||||||
setSelectedUnit(unit);
|
setSelectedUnit(unit);
|
||||||
setPkgCount("0");
|
setCount("0");
|
||||||
setQtyPerPkg(unit.pkg_qty ? String(unit.pkg_qty) : "0");
|
setStep("count");
|
||||||
setStep("qty-per-pkg");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQtyPerPkgConfirm = () => {
|
const handleCountConfirm = () => {
|
||||||
if (qtyPerPkgNum <= 0) return;
|
if (!selectedUnit || countNum <= 0) return;
|
||||||
setStep("pkg-count");
|
const qtyPerUnit = selectedUnit.pkg_qty ?? 0;
|
||||||
|
setPackages((prev) => {
|
||||||
|
if (editingIndex !== null) {
|
||||||
|
return prev.map((p, i) =>
|
||||||
|
i === editingIndex ? { ...p, count: countNum, qtyPerUnit } : p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = prev.findIndex(
|
||||||
|
(p) => p.unit.value === selectedUnit.value
|
||||||
|
);
|
||||||
|
if (existing >= 0) {
|
||||||
|
return prev.map((p, i) =>
|
||||||
|
i === existing ? { ...p, count: p.count + countNum } : p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...prev, { unit: selectedUnit, count: countNum, qtyPerUnit }];
|
||||||
|
});
|
||||||
|
setStep("list");
|
||||||
|
setSelectedUnit(null);
|
||||||
|
setCount("0");
|
||||||
|
setEditingIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePkgCountConfirm = () => {
|
const handleDelete = (idx: number) => {
|
||||||
if (pkgCountNum <= 0 || !selectedUnit) return;
|
setPackages((prev) => prev.filter((_, i) => i !== idx));
|
||||||
const rem = maxQty - pkgCountNum * qtyPerPkgNum;
|
};
|
||||||
if (rem > 0) {
|
|
||||||
setStep("remainder");
|
const handleBack = () => {
|
||||||
|
if (step === "count") {
|
||||||
|
if (editingIndex !== null) {
|
||||||
|
setStep("list");
|
||||||
|
setSelectedUnit(null);
|
||||||
|
setCount("0");
|
||||||
|
setEditingIndex(null);
|
||||||
} else {
|
} else {
|
||||||
setRemUnit(null);
|
setStep("packaging");
|
||||||
setRemPkgCount("0");
|
setSelectedUnit(null);
|
||||||
setRemQtyPerPkg("0");
|
setCount("0");
|
||||||
setStep("confirm");
|
}
|
||||||
|
} else if (step === "packaging") {
|
||||||
|
setStep("list");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* 나머지 포장 handlers */
|
|
||||||
const handleRemSelectPackaging = (unit: PackageUnit) => {
|
|
||||||
if (!selectedUnit) return;
|
|
||||||
setRemUnit(unit);
|
|
||||||
setRemQtyPerPkg(String(remainder));
|
|
||||||
setRemPkgCount("1");
|
|
||||||
setStep("confirm");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemQtyPerPkgConfirm = () => {
|
|
||||||
if (remQtyPerPkgNum <= 0) return;
|
|
||||||
setStep("rem-pkg-count");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemPkgCountConfirm = () => {
|
|
||||||
if (remPkgCountNum <= 0 || !selectedUnit) return;
|
|
||||||
setStep("confirm");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipRemainder = () => {
|
|
||||||
if (!selectedUnit) return;
|
|
||||||
setRemUnit(null);
|
|
||||||
setRemPkgCount("0");
|
|
||||||
setRemQtyPerPkg("0");
|
|
||||||
setStep("confirm");
|
|
||||||
};
|
|
||||||
|
|
||||||
/* 최종 확인 */
|
|
||||||
const handleFinalConfirm = () => {
|
const handleFinalConfirm = () => {
|
||||||
if (primaryQty <= 0 || !selectedUnit) return;
|
if (packages.length === 0) return;
|
||||||
const finalQty = Math.min(totalQty, maxQty);
|
const finalQty = Math.min(totalPackagedQty, maxQty);
|
||||||
const packages: PackageEntry[] = [
|
|
||||||
{ unit: selectedUnit, count: pkgCountNum, qtyPerUnit: qtyPerPkgNum },
|
|
||||||
];
|
|
||||||
if (remUnit && remQty > 0) {
|
|
||||||
packages.push({ unit: remUnit, count: remPkgCountNum, qtyPerUnit: remQtyPerPkgNum });
|
|
||||||
}
|
|
||||||
onConfirm(finalQty, packages);
|
onConfirm(finalQty, packages);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Back navigation */
|
|
||||||
const handleBack = () => {
|
|
||||||
switch (step) {
|
|
||||||
case "qty-per-pkg": setStep("packaging"); break;
|
|
||||||
case "pkg-count": setStep("qty-per-pkg"); break;
|
|
||||||
case "remainder": setStep("pkg-count"); break;
|
|
||||||
case "rem-packaging": setStep("remainder"); break;
|
|
||||||
case "rem-qty-per-pkg": setStep("rem-packaging"); break;
|
|
||||||
case "rem-pkg-count": setStep("rem-qty-per-pkg"); break;
|
|
||||||
case "confirm":
|
|
||||||
if (remUnit) setStep("remainder");
|
|
||||||
else if (remainder > 0) setStep("remainder");
|
|
||||||
else setStep("pkg-count");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
/* Render numpad grid */
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Render helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const renderNumpad = (
|
const renderNumpad = (
|
||||||
currentValue: string,
|
currentValue: string,
|
||||||
onKey: (key: string) => void,
|
onKey: (key: string) => void,
|
||||||
onConfirmStep: () => void,
|
onConfirmStep: () => void,
|
||||||
confirmLabel: string,
|
confirmLabel: string,
|
||||||
confirmDisabled: boolean,
|
confirmDisabled: boolean
|
||||||
) => (
|
) => (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -323,7 +295,6 @@ export function NumberPadModal({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Render packaging grid */
|
|
||||||
const renderPackagingGrid = (onSelect: (unit: PackageUnit) => void) => (
|
const renderPackagingGrid = (onSelect: (unit: PackageUnit) => void) => (
|
||||||
<>
|
<>
|
||||||
{pkgLoading ? (
|
{pkgLoading ? (
|
||||||
@@ -346,7 +317,9 @@ export function NumberPadModal({
|
|||||||
<span className="text-2xl">{unit.icon}</span>
|
<span className="text-2xl">{unit.icon}</span>
|
||||||
<span className="text-center leading-tight">{unit.label}</span>
|
<span className="text-center leading-tight">{unit.label}</span>
|
||||||
{unit.pkg_qty && unit.pkg_qty > 0 && (
|
{unit.pkg_qty && unit.pkg_qty > 0 && (
|
||||||
<span className="text-[10px] text-gray-400">{unit.pkg_qty}EA/개</span>
|
<span className="text-[10px] text-gray-400">
|
||||||
|
{unit.pkg_qty}EA/개
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -355,235 +328,218 @@ export function NumberPadModal({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Header color */
|
/* Header: 단계별 색상 */
|
||||||
const isRemStep = step.startsWith("rem") || step === "remainder";
|
const headerBg =
|
||||||
const headerBg = isRemStep
|
step === "count" && editingIndex !== null
|
||||||
? "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)"
|
? "linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)"
|
||||||
: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)";
|
: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)";
|
||||||
|
|
||||||
|
const headerBadge =
|
||||||
|
step === "count" && editingIndex !== null
|
||||||
|
? "수정"
|
||||||
|
: `최대 ${maxQty.toLocaleString()} EA`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
|
||||||
<div className="relative bg-white w-[90%] max-w-[360px] rounded-2xl shadow-2xl z-10 overflow-hidden">
|
<div className="relative bg-white w-[90%] max-w-[420px] rounded-2xl shadow-2xl z-10 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3" style={{ background: headerBg }}>
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3"
|
||||||
|
style={{ background: headerBg }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{step !== "packaging" && (
|
{step !== "list" && (
|
||||||
<button
|
<button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30 active:scale-95 transition-all"
|
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30 active:scale-95 transition-all"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="text-[13px] text-white/90 bg-white/20 px-3 py-1 rounded-full">
|
<span className="text-[13px] text-white/90 bg-white/20 px-3 py-1 rounded-full">
|
||||||
{isRemStep ? `나머지 ${remainder.toLocaleString()} EA`
|
{headerBadge}
|
||||||
: `최대 ${maxQty.toLocaleString()} EA`
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white hover:bg-white/30 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
{/* ====== LIST: 목록 + 추가 + 확인 ====== */}
|
||||||
{/* ====== 1차: 포장 선택 ====== */}
|
{step === "list" && (
|
||||||
{step === "packaging" && (
|
|
||||||
<>
|
<>
|
||||||
<p className="text-center text-sm font-semibold text-gray-700 mb-4">
|
{/* 요약 (포장 수량 / 미포장 잔량) */}
|
||||||
포장 단위를 선택하세요
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-[10px] text-blue-600 font-semibold">
|
||||||
|
포장 수량
|
||||||
</p>
|
</p>
|
||||||
{renderPackagingGrid(handleSelectPackaging)}
|
<p
|
||||||
</>
|
className="text-lg font-black text-blue-700"
|
||||||
)}
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||||
|
|
||||||
{/* ====== 1차: 개당 수량 ====== */}
|
|
||||||
{step === "qty-per-pkg" && selectedUnit && (
|
|
||||||
<>
|
|
||||||
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
|
|
||||||
{selectedUnit.icon} {selectedUnit.label} 1개당 수량?
|
|
||||||
</p>
|
|
||||||
<p className="text-center text-xs text-gray-400 mb-3">
|
|
||||||
개당 수량(EA)을 입력하세요
|
|
||||||
</p>
|
|
||||||
{renderNumpad(
|
|
||||||
qtyPerPkg,
|
|
||||||
(key) => handleInput(key, setQtyPerPkg, maxQty),
|
|
||||||
handleQtyPerPkgConfirm,
|
|
||||||
"다음",
|
|
||||||
qtyPerPkgNum <= 0,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ====== 1차: 포장 개수 ====== */}
|
|
||||||
{step === "pkg-count" && selectedUnit && (
|
|
||||||
<>
|
|
||||||
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
|
|
||||||
{selectedUnit.icon} {selectedUnit.label} 몇 개?
|
|
||||||
</p>
|
|
||||||
<p className="text-center text-xs text-gray-400 mb-3">
|
|
||||||
포장 개수를 입력하세요
|
|
||||||
</p>
|
|
||||||
{renderNumpad(
|
|
||||||
pkgCount,
|
|
||||||
(key) => handleInput(key, setPkgCount, qtyPerPkgNum > 0 ? Math.floor(maxQty / qtyPerPkgNum) : 9999),
|
|
||||||
handlePkgCountConfirm,
|
|
||||||
"다음",
|
|
||||||
pkgCountNum <= 0,
|
|
||||||
)}
|
|
||||||
{pkgCountNum > 0 && qtyPerPkgNum > 0 && (
|
|
||||||
<div className={`mt-3 text-center text-sm font-semibold px-3 py-2 rounded-lg ${
|
|
||||||
pkgCountNum * qtyPerPkgNum > maxQty
|
|
||||||
? "bg-red-50 text-red-600 border border-red-200"
|
|
||||||
: "bg-blue-50 text-blue-700 border border-blue-200"
|
|
||||||
}`}>
|
|
||||||
{pkgCountNum}{selectedUnit.label} x {qtyPerPkgNum.toLocaleString()}EA = {(pkgCountNum * qtyPerPkgNum).toLocaleString()}EA
|
|
||||||
{pkgCountNum * qtyPerPkgNum > maxQty && (
|
|
||||||
<span className="block text-xs mt-0.5">최대 수량 초과</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ====== 나머지 안내 ====== */}
|
|
||||||
{step === "remainder" && selectedUnit && (
|
|
||||||
<div className="text-center py-2">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg mb-4">
|
|
||||||
<span className="text-sm font-semibold text-blue-700">
|
|
||||||
{pkgCountNum}{selectedUnit.label} x {qtyPerPkgNum.toLocaleString()}EA = {primaryQty.toLocaleString()}EA
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-amber-600 mb-1">
|
|
||||||
나머지 {remainder.toLocaleString()}EA
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
|
||||||
나머지 수량의 포장을 등록하시겠습니까?
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleSkipRemainder}
|
|
||||||
className="flex-1 h-14 rounded-xl text-base font-semibold bg-gray-100 text-gray-700 hover:bg-gray-200 active:scale-95 transition-all"
|
|
||||||
>
|
>
|
||||||
건너뛰기
|
{totalPackagedQty.toLocaleString()} EA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`border rounded-lg px-3 py-2 ${
|
||||||
|
unpackedQty > 0
|
||||||
|
? "bg-amber-50 border-amber-200"
|
||||||
|
: "bg-green-50 border-green-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={`text-[10px] font-semibold ${
|
||||||
|
unpackedQty > 0 ? "text-amber-600" : "text-green-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
미포장
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-lg font-black ${
|
||||||
|
unpackedQty > 0 ? "text-amber-700" : "text-green-700"
|
||||||
|
}`}
|
||||||
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
|
{unpackedQty.toLocaleString()} EA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 등록된 포장 목록 */}
|
||||||
|
<div className="space-y-2 mb-3 max-h-[40vh] overflow-y-auto">
|
||||||
|
{packages.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-sm text-gray-400 border-2 border-dashed border-gray-200 rounded-xl">
|
||||||
|
등록된 포장이 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
packages.map((p, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<span className="text-xl">{p.unit.icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className="text-sm font-bold text-gray-900"
|
||||||
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
|
{p.count.toLocaleString()}
|
||||||
|
{p.unit.label} × {p.qtyPerUnit.toLocaleString()}EA
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold text-green-700"
|
||||||
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||||
|
>
|
||||||
|
= {(p.count * p.qtyPerUnit).toLocaleString()} EA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditFlow(idx)}
|
||||||
|
className="w-9 h-9 rounded-lg bg-blue-100 text-blue-700 hover:bg-blue-200 flex items-center justify-center active:scale-95 transition-all"
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep("rem-packaging")}
|
onClick={() => handleDelete(idx)}
|
||||||
className="flex-1 h-14 rounded-xl text-base font-bold text-white active:scale-95 transition-all"
|
className="w-9 h-9 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 flex items-center justify-center active:scale-95 transition-all"
|
||||||
style={{ background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)" }}
|
title="삭제"
|
||||||
>
|
>
|
||||||
포장 등록
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ====== 나머지: 포장 선택 ====== */}
|
|
||||||
{step === "rem-packaging" && (
|
|
||||||
<>
|
|
||||||
<p className="text-center text-sm font-semibold text-amber-700 mb-1">
|
|
||||||
나머지 {remainder.toLocaleString()}EA 포장 단위
|
|
||||||
</p>
|
|
||||||
<p className="text-center text-xs text-gray-400 mb-4">
|
|
||||||
선택하면 1개 x {remainder.toLocaleString()}EA로 자동 등록됩니다
|
|
||||||
</p>
|
|
||||||
{renderPackagingGrid(handleRemSelectPackaging)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ====== 나머지: 개당 수량 (수동 편집) ====== */}
|
|
||||||
{step === "rem-qty-per-pkg" && remUnit && (
|
|
||||||
<>
|
|
||||||
<p className="text-center text-sm font-semibold text-amber-700 mb-1">
|
|
||||||
{remUnit.icon} {remUnit.label} 1개당 수량?
|
|
||||||
</p>
|
|
||||||
<p className="text-center text-xs text-gray-400 mb-3">개당 수량(EA)을 입력하세요</p>
|
|
||||||
{renderNumpad(
|
|
||||||
remQtyPerPkg,
|
|
||||||
(key) => handleInput(key, setRemQtyPerPkg, remainder),
|
|
||||||
handleRemQtyPerPkgConfirm,
|
|
||||||
"다음",
|
|
||||||
remQtyPerPkgNum <= 0,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ====== 나머지: 포장 개수 (수동 편집) ====== */}
|
|
||||||
{step === "rem-pkg-count" && remUnit && (
|
|
||||||
<>
|
|
||||||
<p className="text-center text-sm font-semibold text-amber-700 mb-1">
|
|
||||||
{remUnit.icon} {remUnit.label} 몇 개?
|
|
||||||
</p>
|
|
||||||
<p className="text-center text-xs text-gray-400 mb-3">포장 개수를 입력하세요</p>
|
|
||||||
{renderNumpad(
|
|
||||||
remPkgCount,
|
|
||||||
(key) => handleInput(key, setRemPkgCount, remQtyPerPkgNum > 0 ? Math.ceil(remainder / remQtyPerPkgNum) : 9999),
|
|
||||||
handleRemPkgCountConfirm,
|
|
||||||
"다음",
|
|
||||||
remPkgCountNum <= 0,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ====== 최종 확인 ====== */}
|
|
||||||
{step === "confirm" && selectedUnit && (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col items-center gap-2.5 py-3">
|
|
||||||
<p className="text-sm font-semibold text-gray-500">최종 확인</p>
|
|
||||||
|
|
||||||
{/* 1차 포장 */}
|
|
||||||
<div className="w-full bg-gradient-to-br from-green-50 to-emerald-50 border border-green-200 rounded-xl p-3 text-center">
|
|
||||||
<p className="text-2xl mb-1">{selectedUnit.icon}</p>
|
|
||||||
<p className="text-base font-bold text-gray-900">
|
|
||||||
{pkgCountNum}{selectedUnit.label} x {qtyPerPkgNum.toLocaleString()}EA
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-black text-green-600" style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
||||||
= {primaryQty.toLocaleString()} EA
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 나머지 포장 */}
|
|
||||||
{remUnit && remQty > 0 && (
|
|
||||||
<div className="w-full bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-3 text-center">
|
|
||||||
<p className="text-[10px] font-bold text-amber-600 mb-1">나머지 포장</p>
|
|
||||||
<p className="text-2xl mb-1">{remUnit.icon}</p>
|
|
||||||
<p className="text-base font-bold text-gray-900">
|
|
||||||
{remPkgCountNum}{remUnit.label} x {remQtyPerPkgNum.toLocaleString()}EA
|
|
||||||
</p>
|
|
||||||
<p className="text-lg font-black text-amber-600" style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
||||||
= {remQty.toLocaleString()} EA
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 합계 */}
|
|
||||||
<div className={`w-full py-2 text-center rounded-lg font-black text-xl ${
|
|
||||||
isOverMax ? "bg-red-50 text-red-500 border border-red-200" : "text-gray-900"
|
|
||||||
}`} style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
||||||
합계 {totalQty.toLocaleString()} EA
|
|
||||||
{isOverMax && (
|
|
||||||
<p className="text-xs font-medium mt-0.5">최대 {maxQty.toLocaleString()}EA로 적용</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 truncate max-w-full px-2">{itemName}</p>
|
{/* 품목명 */}
|
||||||
</div>
|
<p className="text-xs text-gray-400 truncate max-w-full px-1 mb-3 text-center">
|
||||||
|
{itemName}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep("packaging")}
|
onClick={openAddFlow}
|
||||||
className="flex-1 h-14 rounded-xl text-base font-semibold bg-gray-100 text-gray-700 hover:bg-gray-200 active:scale-95 transition-all"
|
disabled={unpackedQty <= 0}
|
||||||
|
className={`flex-1 h-12 rounded-xl text-sm font-bold border-2 border-dashed transition-all ${
|
||||||
|
unpackedQty <= 0
|
||||||
|
? "border-gray-200 text-gray-300 cursor-not-allowed"
|
||||||
|
: "border-blue-400 text-blue-700 hover:bg-blue-50 active:scale-95"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
수정
|
+ 포장단위 추가
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleFinalConfirm}
|
onClick={handleFinalConfirm}
|
||||||
className="flex-1 h-14 rounded-xl text-base font-bold text-white active:scale-95 transition-all"
|
disabled={packages.length === 0}
|
||||||
style={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
|
className={`flex-1 h-12 rounded-xl text-sm font-bold text-white transition-all ${
|
||||||
|
packages.length === 0
|
||||||
|
? "opacity-40 cursor-not-allowed"
|
||||||
|
: "active:scale-95"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
packages.length === 0
|
||||||
|
? "#9ca3af"
|
||||||
|
: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
확인
|
확인
|
||||||
</button>
|
</button>
|
||||||
@@ -591,6 +547,48 @@ export function NumberPadModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ====== PACKAGING: 포장단위 선택 ====== */}
|
||||||
|
{step === "packaging" && (
|
||||||
|
<>
|
||||||
|
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
포장 단위를 선택하세요
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-xs text-gray-400 mb-4">
|
||||||
|
미포장 잔량 {unpackedQty.toLocaleString()} EA
|
||||||
|
</p>
|
||||||
|
{renderPackagingGrid(handleSelectUnit)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ====== COUNT: 개수 입력 ====== */}
|
||||||
|
{step === "count" && selectedUnit && (
|
||||||
|
<>
|
||||||
|
<p className="text-center text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
{selectedUnit.icon} {selectedUnit.label} 몇 개?
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-xs text-gray-400 mb-1">
|
||||||
|
개당 {currentPkgQty.toLocaleString()}EA
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-xs text-blue-600 mb-3">
|
||||||
|
사용 가능 {availableForCurrent.toLocaleString()}EA · 최대{" "}
|
||||||
|
{maxCountForCurrent.toLocaleString()}개
|
||||||
|
</p>
|
||||||
|
{renderNumpad(
|
||||||
|
count,
|
||||||
|
handleInput,
|
||||||
|
handleCountConfirm,
|
||||||
|
editingIndex !== null ? "수정" : "추가",
|
||||||
|
countNum <= 0
|
||||||
|
)}
|
||||||
|
{countNum > 0 && (
|
||||||
|
<div className="mt-3 text-center text-sm font-semibold px-3 py-2 rounded-lg bg-blue-50 text-blue-700 border border-blue-200">
|
||||||
|
{countNum}
|
||||||
|
{selectedUnit.label} × {currentPkgQty.toLocaleString()}EA ={" "}
|
||||||
|
{(countNum * currentPkgQty).toLocaleString()}EA
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
|
|||||||
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
|
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
|
||||||
import { BarcodeScanModal } from "../common/BarcodeScanModal";
|
import { BarcodeScanModal } from "../common/BarcodeScanModal";
|
||||||
import type { CartItemWithId } from "../common/useCartSync";
|
import type { CartItemWithId } from "../common/useCartSync";
|
||||||
|
import { COLOR_MAP } from "../common/theme";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -332,11 +333,7 @@ export function PurchaseInbound({ cart, onCartClick, saving, inboundType, source
|
|||||||
<button
|
<button
|
||||||
onClick={onCartClick}
|
onClick={onCartClick}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60"
|
className={`relative min-w-[144px] min-h-[48px] px-4 rounded-xl flex items-center justify-center gap-2 text-white font-semibold text-sm active:scale-95 transition-all shrink-0 disabled:opacity-60 ${COLOR_MAP.blue.buttonBg} ${COLOR_MAP.blue.buttonBgHover} shadow-[0_4px_12px_rgba(59,130,246,0.3)]`}
|
||||||
style={{
|
|
||||||
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
|
|
||||||
boxShadow: "0 4px 12px rgba(59,130,246,0.3)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
|
<svg className="animate-spin w-6 h-6" fill="none" viewBox="0 0 24 24">
|
||||||
@@ -387,11 +384,7 @@ export function PurchaseInbound({ cart, onCartClick, saving, inboundType, source
|
|||||||
{/* QR/Barcode scan button - glossy v3 */}
|
{/* QR/Barcode scan button - glossy v3 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSupplierScanOpen(true)}
|
onClick={() => setSupplierScanOpen(true)}
|
||||||
className="min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0"
|
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.blue.buttonBg} ${COLOR_MAP.blue.buttonBgHover} shadow-[0_4px_12px_rgba(59,130,246,0.3)]`}
|
||||||
style={{
|
|
||||||
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
|
|
||||||
boxShadow: "0 4px 12px rgba(59,130,246,0.3)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
|
||||||
@@ -439,13 +432,9 @@ export function PurchaseInbound({ cart, onCartClick, saving, inboundType, source
|
|||||||
<button
|
<button
|
||||||
onClick={() => setItemScanOpen(true)}
|
onClick={() => setItemScanOpen(true)}
|
||||||
disabled={!selectedSupplier}
|
disabled={!selectedSupplier}
|
||||||
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${
|
className={`min-w-[48px] min-h-[48px] rounded-xl flex items-center justify-center text-white active:scale-95 transition-all shrink-0 ${COLOR_MAP.blue.buttonBg} ${COLOR_MAP.blue.buttonBgHover} ${
|
||||||
!selectedSupplier ? "opacity-40 cursor-not-allowed" : ""
|
!selectedSupplier ? "opacity-40 cursor-not-allowed" : "shadow-[0_4px_12px_rgba(59,130,246,0.3)]"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
|
||||||
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
|
|
||||||
boxShadow: selectedSupplier ? "0 4px 12px rgba(59,130,246,0.3)" : "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z" />
|
||||||
|
|||||||
@@ -240,9 +240,9 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) {
|
|||||||
setPackagingOpen(true);
|
setPackagingOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePackagingConfirm = (qty: number, packages: PackageEntry[]) => {
|
const handlePackagingConfirm = (_qty: number, packages: PackageEntry[]) => {
|
||||||
if (!packagingTarget) return;
|
if (!packagingTarget) return;
|
||||||
const finalQty = Math.min(qty, packagingTarget.remain_qty);
|
const finalQty = packagingTarget.outbound_qty;
|
||||||
|
|
||||||
cart.updateItemQuantity(
|
cart.updateItemQuantity(
|
||||||
packagingTarget.rowKey,
|
packagingTarget.rowKey,
|
||||||
@@ -906,20 +906,39 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* === Package info (포장 완료 시 — 클릭하면 모달 열림) === */}
|
{/* === Package info (포장 등록 시 — 클릭하면 모달 열림) === */}
|
||||||
{item.packages && item.packages.length > 0 && (
|
{item.packages && item.packages.length > 0 && (() => {
|
||||||
|
const packagedQty = item.packages.reduce(
|
||||||
|
(s, p) => s + p.count * p.qtyPerUnit,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const unpacked = Math.max(0, item.outbound_qty - packagedQty);
|
||||||
|
const isComplete = unpacked === 0;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => openPackaging(item)}
|
onClick={() => openPackaging(item)}
|
||||||
className="mt-2.5 px-3 py-2 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg cursor-pointer active:scale-95 transition-all"
|
className={`mt-2.5 px-3 py-2 border rounded-lg cursor-pointer active:scale-95 transition-all ${
|
||||||
|
isComplete
|
||||||
|
? "bg-gradient-to-r from-green-50 to-emerald-50 border-green-200"
|
||||||
|
: "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[10px] font-bold text-white bg-gradient-to-r from-green-500 to-green-600 px-2 py-0.5 rounded-full">
|
<span
|
||||||
포장완료
|
className={`text-[10px] font-bold text-white px-2 py-0.5 rounded-full ${
|
||||||
|
isComplete
|
||||||
|
? "bg-gradient-to-r from-green-500 to-green-600"
|
||||||
|
: "bg-gradient-to-r from-amber-500 to-orange-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isComplete ? "포장완료" : "부분포장"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] font-semibold text-green-600">
|
<span
|
||||||
{item.packages
|
className={`text-[11px] font-semibold ${
|
||||||
.reduce((s, p) => s + p.count * p.qtyPerUnit, 0)
|
isComplete ? "text-green-600" : "text-amber-600"
|
||||||
.toLocaleString()}{" "}
|
}`}
|
||||||
|
>
|
||||||
|
{packagedQty.toLocaleString()}{" "}
|
||||||
EA
|
EA
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -938,10 +957,19 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{!isComplete && (
|
||||||
|
<div className="mt-1 pt-1 border-t border-amber-200 flex items-center justify-between text-[11px]">
|
||||||
|
<span className="text-amber-700 font-medium">미포장</span>
|
||||||
|
<span className="font-bold text-amber-700">
|
||||||
|
{unpacked.toLocaleString()} EA
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1113,7 +1141,7 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) {
|
|||||||
setPackagingTarget(null);
|
setPackagingTarget(null);
|
||||||
}}
|
}}
|
||||||
onConfirm={handlePackagingConfirm}
|
onConfirm={handlePackagingConfirm}
|
||||||
maxQty={packagingTarget.remain_qty}
|
maxQty={packagingTarget.outbound_qty}
|
||||||
itemName={packagingTarget.item_name}
|
itemName={packagingTarget.item_name}
|
||||||
itemNumber={packagingTarget.item_code}
|
itemNumber={packagingTarget.item_code}
|
||||||
initialPackages={packagingTarget.packages}
|
initialPackages={packagingTarget.packages}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface AcceptProcessModalProps {
|
|||||||
onConfirm: (qty: number) => void;
|
onConfirm: (qty: number) => void;
|
||||||
maxQty: number;
|
maxQty: number;
|
||||||
processName: string;
|
processName: string;
|
||||||
seqNo: string;
|
seqNo: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { usePopSettings } from "@/hooks/pop/usePopSettings";
|
import { usePopSettings } from "@/hooks/pop/usePopSettings";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from "./DefectTypeModal";
|
} from "./DefectTypeModal";
|
||||||
import { ProcessTimer, type TimerStatus } from "./ProcessTimer";
|
import { ProcessTimer, type TimerStatus } from "./ProcessTimer";
|
||||||
import { MaterialInputSection } from "./sections/MaterialInputSection";
|
import { MaterialInputSection } from "./sections/MaterialInputSection";
|
||||||
|
import { isReworkProcess, type WorkOrderProcessView } from "./types";
|
||||||
|
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -27,22 +29,22 @@ import { MaterialInputSection } from "./sections/MaterialInputSection";
|
|||||||
interface ProcessData {
|
interface ProcessData {
|
||||||
id: string;
|
id: string;
|
||||||
wo_id: string;
|
wo_id: string;
|
||||||
seq_no: string;
|
seq_no: number;
|
||||||
process_code: string;
|
process_code: string;
|
||||||
process_name: string;
|
process_name: string;
|
||||||
status: string;
|
status: string;
|
||||||
plan_qty: string;
|
plan_qty: number;
|
||||||
input_qty: string;
|
input_qty: number;
|
||||||
good_qty: string;
|
good_qty: number;
|
||||||
defect_qty: string;
|
defect_qty: number;
|
||||||
concession_qty: string;
|
concession_qty: number;
|
||||||
total_production_qty: string;
|
total_production_qty: number;
|
||||||
parent_process_id: string | null;
|
parent_process_id: string | null;
|
||||||
result_status: string;
|
result_status: string;
|
||||||
result_note: string;
|
result_note: string;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
paused_at: string | null;
|
paused_at: string | null;
|
||||||
total_paused_time: string | null;
|
total_paused_time: number;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
actual_work_time: string | null;
|
actual_work_time: string | null;
|
||||||
accepted_at: string | null;
|
accepted_at: string | null;
|
||||||
@@ -50,11 +52,52 @@ interface ProcessData {
|
|||||||
defect_detail: string | null;
|
defect_detail: string | null;
|
||||||
target_warehouse_id: string | null;
|
target_warehouse_id: string | null;
|
||||||
target_location_code: string | null;
|
target_location_code: string | null;
|
||||||
is_rework: string;
|
is_rework: boolean;
|
||||||
routing_detail_id: string | null;
|
routing_detail_id: string | null;
|
||||||
batch_id?: string | null;
|
batch_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** raw work_order_process row → ProcessData 정규화 */
|
||||||
|
function normalizeProcessData(raw: Record<string, unknown>): ProcessData {
|
||||||
|
const toInt = (v: unknown): number => {
|
||||||
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
|
if (v == null) return 0;
|
||||||
|
const n = parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
const s = (v: unknown) => (v == null ? null : String(v));
|
||||||
|
return {
|
||||||
|
id: String(raw.id || ""),
|
||||||
|
wo_id: String(raw.wo_id || ""),
|
||||||
|
seq_no: toInt(raw.seq_no),
|
||||||
|
process_code: String(raw.process_code || ""),
|
||||||
|
process_name: String(raw.process_name || ""),
|
||||||
|
status: String(raw.status || ""),
|
||||||
|
plan_qty: toInt(raw.plan_qty),
|
||||||
|
input_qty: toInt(raw.input_qty),
|
||||||
|
good_qty: toInt(raw.good_qty),
|
||||||
|
defect_qty: toInt(raw.defect_qty),
|
||||||
|
concession_qty: toInt(raw.concession_qty),
|
||||||
|
total_production_qty: toInt(raw.total_production_qty),
|
||||||
|
parent_process_id: s(raw.parent_process_id),
|
||||||
|
result_status: String(raw.result_status || ""),
|
||||||
|
result_note: String(raw.result_note || ""),
|
||||||
|
started_at: s(raw.started_at),
|
||||||
|
paused_at: s(raw.paused_at),
|
||||||
|
total_paused_time: toInt(raw.total_paused_time),
|
||||||
|
completed_at: s(raw.completed_at),
|
||||||
|
actual_work_time: s(raw.actual_work_time),
|
||||||
|
accepted_at: s(raw.accepted_at),
|
||||||
|
accepted_by: s(raw.accepted_by),
|
||||||
|
defect_detail: s(raw.defect_detail),
|
||||||
|
target_warehouse_id: s(raw.target_warehouse_id),
|
||||||
|
target_location_code: s(raw.target_location_code),
|
||||||
|
is_rework: isReworkProcess(raw as { is_rework?: string | boolean | null }),
|
||||||
|
routing_detail_id: s(raw.routing_detail_id),
|
||||||
|
batch_id: s(raw.batch_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkInstructionInfo {
|
interface WorkInstructionInfo {
|
||||||
work_instruction_no: string;
|
work_instruction_no: string;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
@@ -429,7 +472,8 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
size: 1,
|
size: 1,
|
||||||
filters: { id: processId },
|
filters: { id: processId },
|
||||||
});
|
});
|
||||||
const procData = (procRes.data?.[0] ?? null) as ProcessData | null;
|
const procRaw = procRes.data?.[0] as Record<string, unknown> | undefined;
|
||||||
|
const procData = procRaw ? normalizeProcessData(procRaw) : null;
|
||||||
if (procData) {
|
if (procData) {
|
||||||
setProcess(procData);
|
setProcess(procData);
|
||||||
|
|
||||||
@@ -552,10 +596,11 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
size: 100,
|
size: 100,
|
||||||
filters: { wo_id: procData.wo_id },
|
filters: { wo_id: procData.wo_id },
|
||||||
});
|
});
|
||||||
const allSiblings = (plRes.data ?? []) as ProcessData[];
|
const allSiblingsRaw = (plRes.data ?? []) as Record<string, unknown>[];
|
||||||
|
const allSiblings = allSiblingsRaw.map(normalizeProcessData);
|
||||||
const masters = allSiblings
|
const masters = allSiblings
|
||||||
.filter((p) => !p.parent_process_id)
|
.filter((p) => !p.parent_process_id)
|
||||||
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
|
.sort((a, b) => a.seq_no - b.seq_no);
|
||||||
// 중복 제거
|
// 중복 제거
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
setProcessList(
|
setProcessList(
|
||||||
@@ -768,9 +813,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
paused_at: null,
|
paused_at: null,
|
||||||
total_paused_time: String(
|
total_paused_time: prev.total_paused_time + pausedSec,
|
||||||
(parseInt(prev.total_paused_time || "0", 10) || 0) + pausedSec,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action === "complete")
|
if (action === "complete")
|
||||||
@@ -798,7 +841,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
fetchProcess();
|
fetchProcess();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { message?: string } } };
|
const err = error as { response?: { data?: { message?: string } } };
|
||||||
alert(err.response?.data?.message || "타이머 오류");
|
toast.error(err.response?.data?.message || "타이머 오류");
|
||||||
fetchProcess(); // 실패 시 서버 상태로 복원
|
fetchProcess(); // 실패 시 서버 상태로 복원
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -929,7 +972,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
alert("체크리스트 저장 실패");
|
toast.error("체크리스트 저장 실패");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -939,7 +982,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
|
|
||||||
const handleSaveResult = async () => {
|
const handleSaveResult = async () => {
|
||||||
if (productionQty <= 0) {
|
if (productionQty <= 0) {
|
||||||
alert("생산수량을 입력해주세요.");
|
toast.warning("생산수량을 입력해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -967,16 +1010,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
setResultNote("");
|
setResultNote("");
|
||||||
loadHistory();
|
loadHistory();
|
||||||
if (d?.status === "completed") {
|
if (d?.status === "completed") {
|
||||||
alert("모든 수량이 완료되어 자동 확정되었습니다.");
|
toast.success("모든 수량이 완료되어 자동 확정되었습니다.");
|
||||||
} else {
|
} else {
|
||||||
alert("실적이 저장되었습니다.");
|
toast.success("실적이 저장되었습니다.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert(res.data?.message || "저장 실패");
|
toast.error(res.data?.message || "저장 실패");
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { message?: string } } };
|
const err = error as { response?: { data?: { message?: string } } };
|
||||||
alert(err.response?.data?.message || "실적 저장 중 오류");
|
toast.error(err.response?.data?.message || "실적 저장 중 오류");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -994,13 +1037,13 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
});
|
});
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
await fetchProcess();
|
await fetchProcess();
|
||||||
alert("실적이 확정되었습니다.");
|
toast.success("실적이 확정되었습니다.");
|
||||||
} else {
|
} else {
|
||||||
alert(res.data?.message || "확정 실패");
|
toast.error(res.data?.message || "확정 실패");
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { message?: string } } };
|
const err = error as { response?: { data?: { message?: string } } };
|
||||||
alert(err.response?.data?.message || "확정 중 오류");
|
toast.error(err.response?.data?.message || "확정 중 오류");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1030,7 +1073,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
|
|
||||||
const handleInbound = () => {
|
const handleInbound = () => {
|
||||||
if (!selectedWarehouse) {
|
if (!selectedWarehouse) {
|
||||||
alert("창고를 선택해주세요.");
|
toast.warning("창고를 선택해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
askConfirm(
|
askConfirm(
|
||||||
@@ -1050,9 +1093,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
);
|
);
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
setInboundDone(true);
|
setInboundDone(true);
|
||||||
alert(`재고 입고 완료: ${res.data.data?.qty || 0}개`);
|
toast.success(`재고 입고 완료: ${res.data.data?.qty || 0}개`);
|
||||||
} else {
|
} else {
|
||||||
alert(res.data?.message || "입고 실패");
|
toast.error(res.data?.message || "입고 실패");
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as {
|
const err = error as {
|
||||||
@@ -1061,9 +1104,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
const msg = err.response?.data?.message;
|
const msg = err.response?.data?.message;
|
||||||
if (err.response?.status === 409) {
|
if (err.response?.status === 409) {
|
||||||
setInboundDone(true);
|
setInboundDone(true);
|
||||||
alert(msg || "이미 입고 완료");
|
toast.info(msg || "이미 입고 완료");
|
||||||
} else {
|
} else {
|
||||||
alert(msg || "입고 중 오류");
|
toast.error(msg || "입고 중 오류");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -1082,10 +1125,10 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const goodQtyThisBatch = productionQty - totalDefectQty;
|
const goodQtyThisBatch = productionQty - totalDefectQty;
|
||||||
const inputQty = parseInt(process?.input_qty || "0", 10);
|
const inputQty = process?.input_qty ?? 0;
|
||||||
const totalProduced = parseInt(process?.total_production_qty || "0", 10);
|
const totalProduced = process?.total_production_qty ?? 0;
|
||||||
const accumulatedGood = parseInt(process?.good_qty || "0", 10);
|
const accumulatedGood = process?.good_qty ?? 0;
|
||||||
const accumulatedDefect = parseInt(process?.defect_qty || "0", 10);
|
const accumulatedDefect = process?.defect_qty ?? 0;
|
||||||
const remaining = Math.max(0, inputQty - totalProduced);
|
const remaining = Math.max(0, inputQty - totalProduced);
|
||||||
const isCompleted = process?.status === "completed";
|
const isCompleted = process?.status === "completed";
|
||||||
const isConfirmed = process?.result_status === "confirmed";
|
const isConfirmed = process?.result_status === "confirmed";
|
||||||
@@ -1177,7 +1220,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="text-white/40 text-sm">지시</span>
|
<span className="text-white/40 text-sm">지시</span>
|
||||||
<span className="text-white font-medium text-base">
|
<span className="text-white font-medium text-base">
|
||||||
{parseInt(process.plan_qty || "0", 10).toLocaleString()}
|
{process.plan_qty.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
@@ -1203,7 +1246,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
? "진행중"
|
? "진행중"
|
||||||
: process.status}
|
: process.status}
|
||||||
</span>
|
</span>
|
||||||
{process.is_rework === "Y" && (
|
{process.is_rework && (
|
||||||
<span className="text-[13px] font-bold px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 shrink-0">
|
<span className="text-[13px] font-bold px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 shrink-0">
|
||||||
재작업
|
재작업
|
||||||
</span>
|
</span>
|
||||||
@@ -1750,9 +1793,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
alert(res.ok ? "사진 첨부 완료" : "첨부 실패");
|
res.ok ? toast.success("사진 첨부 완료") : toast.error("첨부 실패");
|
||||||
} catch {
|
} catch {
|
||||||
alert("첨부 오류");
|
toast.error("첨부 오류");
|
||||||
}
|
}
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
@@ -2057,9 +2100,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
alert(res.ok ? "사진 첨부 완료" : "첨부 실패");
|
res.ok ? toast.success("사진 첨부 완료") : toast.error("첨부 실패");
|
||||||
} catch {
|
} catch {
|
||||||
alert("첨부 오류");
|
toast.error("첨부 오류");
|
||||||
}
|
}
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
@@ -2312,8 +2355,8 @@ function ChecklistRow({
|
|||||||
|
|
||||||
if (isPassed === "N") {
|
if (isPassed === "N") {
|
||||||
const rangeStr = `${item.lower_limit || ""}~${item.upper_limit || ""}`;
|
const rangeStr = `${item.lower_limit || ""}~${item.upper_limit || ""}`;
|
||||||
alert(
|
toast.warning(
|
||||||
`⚠️ 기준 초과!\n\n입력값: ${localValue}\n허용 범위: ${rangeStr}\n\n불합격으로 기록됩니다.`,
|
`기준 초과! 입력값: ${localValue} / 허용 범위: ${rangeStr} — 불합격으로 기록됩니다.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2591,12 +2634,12 @@ function ChecklistRow({
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("사진 업로드 완료");
|
toast.success("사진 업로드 완료");
|
||||||
} else {
|
} else {
|
||||||
alert("업로드 실패");
|
toast.error("업로드 실패");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("업로드 중 오류");
|
toast.error("업로드 중 오류");
|
||||||
}
|
}
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import { ConfirmModal } from "../common/ConfirmModal";
|
import { ConfirmModal } from "../common/ConfirmModal";
|
||||||
import { AcceptProcessModal } from "./AcceptProcessModal";
|
import { AcceptProcessModal } from "./AcceptProcessModal";
|
||||||
import { ProcessDetailModal, type ProcessStep } from "./ProcessDetailModal";
|
import { ProcessDetailModal, type ProcessStep } from "./ProcessDetailModal";
|
||||||
import { ProcessWork } from "./ProcessWork";
|
import {
|
||||||
|
isReworkProcess,
|
||||||
|
type WorkOrderProcessView,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const POP_NEW_PROD_STATE_KEY = "pop-new-production-process-state";
|
||||||
|
|
||||||
/* 텍스트가 넘칠 때 자동 슬라이드 (마키) */
|
/* 텍스트가 넘칠 때 자동 슬라이드 (마키) */
|
||||||
function AutoScrollText({
|
function AutoScrollText({
|
||||||
@@ -83,31 +89,8 @@ interface WorkInstruction {
|
|||||||
worker: string;
|
worker: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkOrderProcess {
|
/** Phase D: 정규화 View 재export (기존 타입명 유지) */
|
||||||
id: string;
|
type WorkOrderProcess = WorkOrderProcessView;
|
||||||
wo_id: string;
|
|
||||||
seq_no: string;
|
|
||||||
process_code: string;
|
|
||||||
process_name: string;
|
|
||||||
status: "acceptable" | "waiting" | "in_progress" | "completed";
|
|
||||||
plan_qty: string;
|
|
||||||
input_qty: string;
|
|
||||||
good_qty: string;
|
|
||||||
defect_qty: string;
|
|
||||||
concession_qty: string;
|
|
||||||
total_production_qty: string;
|
|
||||||
parent_process_id: string | null;
|
|
||||||
is_rework: string;
|
|
||||||
rework_source_id: string | null;
|
|
||||||
result_status: string;
|
|
||||||
started_at: string | null;
|
|
||||||
completed_at: string | null;
|
|
||||||
accepted_by?: string;
|
|
||||||
accepted_at?: string | null;
|
|
||||||
created_date?: string;
|
|
||||||
batch_id?: string | null;
|
|
||||||
equipment_code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProcessMng {
|
interface ProcessMng {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -206,161 +189,6 @@ const COLS_GRID_CLASS: Record<number, string> = {
|
|||||||
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
|
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Fullscreen Work Modal with My-Work Drawer */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function FullscreenWorkModal({
|
|
||||||
processId,
|
|
||||||
myProcesses,
|
|
||||||
instructionMap,
|
|
||||||
itemNameMap,
|
|
||||||
multiBatchInfo,
|
|
||||||
onSwitch,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
processId: string;
|
|
||||||
myProcesses: WorkOrderProcess[];
|
|
||||||
instructionMap: Record<string, WorkInstruction>;
|
|
||||||
itemNameMap: Record<string, string>;
|
|
||||||
multiBatchInfo: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }>;
|
|
||||||
onSwitch: (id: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[100] bg-white flex">
|
|
||||||
{/* Drawer tab handle (left edge, middle) */}
|
|
||||||
<button
|
|
||||||
onClick={() => setDrawerOpen((v) => !v)}
|
|
||||||
className={`absolute left-0 top-1/2 -translate-y-1/2 z-[115] h-20 w-5 flex items-center justify-center transition-all ${
|
|
||||||
drawerOpen ? "left-[280px]" : "left-0"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
background: "#6d28d9",
|
|
||||||
borderRadius: "0 8px 8px 0",
|
|
||||||
boxShadow: "2px 0 8px rgba(0,0,0,0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 text-white transition-transform ${drawerOpen ? "rotate-180" : ""}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={3}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Drawer overlay */}
|
|
||||||
{drawerOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/20 z-[111]"
|
|
||||||
onClick={() => setDrawerOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drawer panel */}
|
|
||||||
<div
|
|
||||||
className={`absolute left-0 top-0 bottom-0 z-[112] bg-white border-r border-gray-200 shadow-xl transition-transform duration-300 flex flex-col ${
|
|
||||||
drawerOpen ? "translate-x-0" : "-translate-x-full"
|
|
||||||
}`}
|
|
||||||
style={{ width: 280 }}
|
|
||||||
>
|
|
||||||
<div className="p-4 border-b border-gray-100">
|
|
||||||
<h3 className="text-base font-bold text-gray-900">내 접수 목록</h3>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">{myProcesses.length}건</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
|
||||||
{myProcesses.map((proc) => {
|
|
||||||
const wi = instructionMap[proc.wo_id];
|
|
||||||
const isActive = proc.id === processId;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={proc.id}
|
|
||||||
onClick={() => {
|
|
||||||
onSwitch(proc.id);
|
|
||||||
setDrawerOpen(false);
|
|
||||||
}}
|
|
||||||
className={`w-full text-left p-3.5 rounded-xl mb-2 transition-all active:scale-[0.98] ${
|
|
||||||
isActive
|
|
||||||
? "bg-blue-50 border-2 border-blue-400"
|
|
||||||
: "bg-gray-50 border border-gray-200 hover:bg-gray-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-base font-bold text-gray-900 mb-1">
|
|
||||||
{wi?.work_instruction_no || "작업지시"}
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const bInfo = multiBatchInfo[proc.id];
|
|
||||||
if (!bInfo) return null;
|
|
||||||
const typeLabel = bInfo.itemType || "";
|
|
||||||
return bInfo.isMulti ? (
|
|
||||||
<span className="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded-full self-start mb-0.5">
|
|
||||||
다중 {bInfo.index}/{bInfo.total}{typeLabel ? ` · ${typeLabel}` : ""}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="bg-gray-100 text-gray-600 text-xs font-bold px-2 py-0.5 rounded-full self-start mb-0.5">
|
|
||||||
단일{typeLabel ? ` · ${typeLabel}` : ""}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<AutoScrollText className="text-sm text-gray-500 mb-0.5">
|
|
||||||
📦 {proc.batch_id
|
|
||||||
? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})`
|
|
||||||
: `${wi?.item_name || ""}${wi?.item_code || wi?.item_number ? `(${wi?.item_code || wi?.item_number})` : ""}`}
|
|
||||||
</AutoScrollText>
|
|
||||||
<div className="text-sm text-gray-600 mb-0.5">
|
|
||||||
{proc.process_name} · {proc.equipment_code || "미배정"}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-blue-600">
|
|
||||||
접수 {proc.input_qty || 0}EA
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{myProcesses.length === 0 && (
|
|
||||||
<p className="text-sm text-gray-400 text-center py-8">
|
|
||||||
접수한 작업이 없습니다
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 z-[110] w-12 h-12 rounded-full bg-black/10 hover:bg-black/20 flex items-center justify-center active:scale-95 transition-all"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6 text-gray-700"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* ProcessWork content */}
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<ProcessWork key={processId} processId={processId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Compressed Process Steps (center-aligned) */
|
/* Compressed Process Steps (center-aligned) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -374,7 +202,7 @@ function CompressedProcessSteps({
|
|||||||
allProcesses,
|
allProcesses,
|
||||||
}: {
|
}: {
|
||||||
processes: WorkOrderProcess[];
|
processes: WorkOrderProcess[];
|
||||||
currentSeqNo: string;
|
currentSeqNo: number;
|
||||||
status: string;
|
status: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
batchId?: string;
|
batchId?: string;
|
||||||
@@ -386,7 +214,7 @@ function CompressedProcessSteps({
|
|||||||
(!batchId && !p.batch_id) ||
|
(!batchId && !p.batch_id) ||
|
||||||
(batchId && p.batch_id === batchId)
|
(batchId && p.batch_id === batchId)
|
||||||
))
|
))
|
||||||
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
|
.sort((a, b) => a.seq_no - b.seq_no);
|
||||||
|
|
||||||
if (sorted.length === 0) return null;
|
if (sorted.length === 0) return null;
|
||||||
|
|
||||||
@@ -396,7 +224,7 @@ function CompressedProcessSteps({
|
|||||||
// For completed status: batch_id 기반 진행률 표시
|
// For completed status: batch_id 기반 진행률 표시
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
// 같은 batch_id를 가진 SPLIT들이 어느 seq까지 완료했는지 추적
|
// 같은 batch_id를 가진 SPLIT들이 어느 seq까지 완료했는지 추적
|
||||||
let maxCompletedSeq = parseInt(currentSeqNo, 10); // 최소한 현재 seq까지는 완료
|
let maxCompletedSeq = currentSeqNo; // 최소한 현재 seq까지는 완료
|
||||||
|
|
||||||
if (batchId && allProcesses) {
|
if (batchId && allProcesses) {
|
||||||
const batchSplits = allProcesses.filter(
|
const batchSplits = allProcesses.filter(
|
||||||
@@ -406,23 +234,26 @@ function CompressedProcessSteps({
|
|||||||
p.status === "completed",
|
p.status === "completed",
|
||||||
);
|
);
|
||||||
for (const s of batchSplits) {
|
for (const s of batchSplits) {
|
||||||
const sSeq = parseInt(s.seq_no, 10);
|
const sSeq = s.seq_no;
|
||||||
if (sSeq > maxCompletedSeq) maxCompletedSeq = sSeq;
|
if (sSeq > maxCompletedSeq) maxCompletedSeq = sSeq;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const completedCount = sorted.filter(
|
const completedCount = sorted.filter(
|
||||||
(p) => parseInt(p.seq_no, 10) <= maxCompletedSeq,
|
(p) => p.seq_no <= maxCompletedSeq,
|
||||||
).length;
|
).length;
|
||||||
const allDone = completedCount === sorted.length;
|
const allDone = completedCount === sorted.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center gap-0.5 mb-3 py-2 px-3 bg-green-50 rounded-xl cursor-pointer hover:bg-green-100 transition"
|
className="flex items-center justify-center gap-0.5 mb-3 py-2 px-3 bg-green-50 rounded-xl cursor-pointer hover:bg-green-100 transition"
|
||||||
onClick={onClick}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{sorted.map((proc, idx) => {
|
{sorted.map((proc, idx) => {
|
||||||
const seqNum = parseInt(proc.seq_no, 10);
|
const seqNum = proc.seq_no;
|
||||||
const isDone = seqNum <= maxCompletedSeq;
|
const isDone = seqNum <= maxCompletedSeq;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={proc.id}>
|
<React.Fragment key={proc.id}>
|
||||||
@@ -534,7 +365,14 @@ function CompressedProcessSteps({
|
|||||||
className={`flex items-center justify-center gap-1 mb-3 py-3 px-4 bg-gray-50 rounded-xl transition ${
|
className={`flex items-center justify-center gap-1 mb-3 py-3 px-4 bg-gray-50 rounded-xl transition ${
|
||||||
isClickable ? "cursor-pointer hover:bg-gray-100" : ""
|
isClickable ? "cursor-pointer hover:bg-gray-100" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={isClickable ? onClick : undefined}
|
onClick={
|
||||||
|
isClickable
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Collapsed before */}
|
{/* Collapsed before */}
|
||||||
{beforeCollapsed > 0 && (
|
{beforeCollapsed > 0 && (
|
||||||
@@ -918,10 +756,10 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
processId: string;
|
processId: string;
|
||||||
processName: string;
|
processName: string;
|
||||||
seqNo: string;
|
seqNo: number;
|
||||||
maxQty: number;
|
maxQty: number;
|
||||||
reworkSourceId?: string;
|
reworkSourceId?: string;
|
||||||
}>({ open: false, processId: "", processName: "", seqNo: "", maxQty: 0 });
|
}>({ open: false, processId: "", processName: "", seqNo: 0, maxQty: 0 });
|
||||||
const [acceptLoading, setAcceptLoading] = useState(false);
|
const [acceptLoading, setAcceptLoading] = useState(false);
|
||||||
|
|
||||||
const [cancelConfirm, setCancelConfirm] = useState<{
|
const [cancelConfirm, setCancelConfirm] = useState<{
|
||||||
@@ -960,42 +798,23 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
return map;
|
return map;
|
||||||
}, [allProcesses]);
|
}, [allProcesses]);
|
||||||
|
|
||||||
/** 다중품목 판단: wo_id별 DISTINCT batch_id 집합 + 순번 매핑 */
|
/** 서버 응답 batch_count/batch_index를 Drawer/카드 기존 구조로 어댑팅 */
|
||||||
const multiBatchInfo = useMemo(() => {
|
const multiBatchInfo = useMemo(() => {
|
||||||
// wo_id → 고유 batch_id 목록 (마스터 행 기준)
|
const map: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }> = {};
|
||||||
const woBatches: Record<string, string[]> = {};
|
|
||||||
for (const proc of allProcesses) {
|
for (const proc of allProcesses) {
|
||||||
if (proc.parent_process_id) continue; // 마스터만
|
const total = Math.max(Number(proc.batch_count ?? 1) || 1, 1);
|
||||||
if (!proc.wo_id) continue;
|
const index = Number(proc.batch_index ?? 1) || 1;
|
||||||
if (!woBatches[proc.wo_id]) woBatches[proc.wo_id] = [];
|
const isMulti = total > 1;
|
||||||
const bid = proc.batch_id || "";
|
const bid = proc.batch_id || "";
|
||||||
if (bid && !woBatches[proc.wo_id].includes(bid)) {
|
|
||||||
woBatches[proc.wo_id].push(bid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// proc.id → { isMulti, index, total, itemType }
|
|
||||||
const info: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }> = {};
|
|
||||||
for (const proc of allProcesses) {
|
|
||||||
if (!proc.wo_id) continue;
|
|
||||||
const batches = woBatches[proc.wo_id] || [];
|
|
||||||
const bid = proc.batch_id || "";
|
|
||||||
const isMulti = batches.length > 1;
|
|
||||||
const index = bid ? batches.indexOf(bid) + 1 : 1;
|
|
||||||
const total = Math.max(batches.length, 1);
|
|
||||||
// item_type: batch_id가 있으면 itemTypeMap에서, 없으면 WI의 item_number로
|
|
||||||
let itemType = "";
|
let itemType = "";
|
||||||
if (bid) {
|
if (bid) itemType = itemTypeMap[bid] || "";
|
||||||
itemType = itemTypeMap[bid] || "";
|
|
||||||
}
|
|
||||||
if (!itemType) {
|
if (!itemType) {
|
||||||
const wi = instructionMap[proc.wo_id];
|
const wi = instructionMap[proc.wo_id];
|
||||||
if (wi?.item_number) {
|
if (wi?.item_number) itemType = itemTypeMap[wi.item_number] || "";
|
||||||
itemType = itemTypeMap[wi.item_number] || "";
|
|
||||||
}
|
}
|
||||||
|
map[proc.id] = { isMulti, index, total, itemType };
|
||||||
}
|
}
|
||||||
info[proc.id] = { isMulti, index, total, itemType };
|
return map;
|
||||||
}
|
|
||||||
return info;
|
|
||||||
}, [allProcesses, itemTypeMap, instructionMap]);
|
}, [allProcesses, itemTypeMap, instructionMap]);
|
||||||
|
|
||||||
const masterProcesses = useMemo(() => {
|
const masterProcesses = useMemo(() => {
|
||||||
@@ -1007,9 +826,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
!p.parent_process_id || // 마스터 행
|
!p.parent_process_id || // 마스터 행
|
||||||
p.status === "in_progress" ||
|
p.status === "in_progress" ||
|
||||||
p.status === "completed" || // 분할 행
|
p.status === "completed" || // 분할 행
|
||||||
p.is_rework === "Y" ||
|
isReworkProcess(p); // 재작업
|
||||||
p.is_rework === "true" ||
|
|
||||||
p.is_rework === "1"; // 재작업
|
|
||||||
if (include) seen.add(p.id);
|
if (include) seen.add(p.id);
|
||||||
return include;
|
return include;
|
||||||
});
|
});
|
||||||
@@ -1031,10 +848,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
const filteredProcesses = useMemo(() => {
|
const filteredProcesses = useMemo(() => {
|
||||||
if (selectedProcess === "__all__") return []; // 공정 미선택 시 빈 목록
|
if (selectedProcess === "__all__") return []; // 공정 미선택 시 빈 목록
|
||||||
return masterProcesses.filter((proc) => {
|
return masterProcesses.filter((proc) => {
|
||||||
const isRework =
|
const isRework = isReworkProcess(proc);
|
||||||
proc.is_rework === "Y" ||
|
|
||||||
proc.is_rework === "true" ||
|
|
||||||
proc.is_rework === "1";
|
|
||||||
const isMaster = !proc.parent_process_id;
|
const isMaster = !proc.parent_process_id;
|
||||||
// 완료/진행중 탭에서는 SPLIT만 표시 (마스터 제외)
|
// 완료/진행중 탭에서는 SPLIT만 표시 (마스터 제외)
|
||||||
if (
|
if (
|
||||||
@@ -1076,10 +890,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
/* ---- Tab counts ---- */
|
/* ---- Tab counts ---- */
|
||||||
const tabCounts = useMemo(() => {
|
const tabCounts = useMemo(() => {
|
||||||
const preFiltered = masterProcesses.filter((proc) => {
|
const preFiltered = masterProcesses.filter((proc) => {
|
||||||
const isRework =
|
const isRework = isReworkProcess(proc);
|
||||||
proc.is_rework === "Y" ||
|
|
||||||
proc.is_rework === "true" ||
|
|
||||||
proc.is_rework === "1";
|
|
||||||
// 재작업 카드는 공정 필터 무시 (모든 공정에서 표시)
|
// 재작업 카드는 공정 필터 무시 (모든 공정에서 표시)
|
||||||
if (
|
if (
|
||||||
selectedProcess !== "__all__" &&
|
selectedProcess !== "__all__" &&
|
||||||
@@ -1105,10 +916,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
};
|
};
|
||||||
for (const proc of preFiltered) {
|
for (const proc of preFiltered) {
|
||||||
const isMaster = !proc.parent_process_id;
|
const isMaster = !proc.parent_process_id;
|
||||||
const isRw =
|
const isRw = isReworkProcess(proc);
|
||||||
proc.is_rework === "Y" ||
|
|
||||||
proc.is_rework === "true" ||
|
|
||||||
proc.is_rework === "1";
|
|
||||||
// 리워크 마스터가 in_progress/completed면 SPLIT이 있으므로 카운트 제외
|
// 리워크 마스터가 in_progress/completed면 SPLIT이 있으므로 카운트 제외
|
||||||
if (
|
if (
|
||||||
isRw &&
|
isRw &&
|
||||||
@@ -1136,7 +944,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
const openAcceptModal = async (
|
const openAcceptModal = async (
|
||||||
processId: string,
|
processId: string,
|
||||||
processName: string,
|
processName: string,
|
||||||
seqNo: string,
|
seqNo: number,
|
||||||
reworkSourceId?: string,
|
reworkSourceId?: string,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
@@ -1154,7 +962,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
reworkSourceId,
|
reworkSourceId,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
alert("접수가능량 조회 실패");
|
toast.error("접수가능량 조회 실패");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1174,24 +982,59 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
setAcceptModal((m) => ({ ...m, open: false }));
|
setAcceptModal((m) => ({ ...m, open: false }));
|
||||||
refetch();
|
refetch();
|
||||||
} else {
|
} else {
|
||||||
alert(res.data?.message || "접수 실패");
|
toast.error(res.data?.message || "접수 실패");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.response?.data?.message || "접수 중 오류 발생");
|
toast.error(error.response?.data?.message || "접수 중 오류 발생");
|
||||||
} finally {
|
} finally {
|
||||||
setAcceptLoading(false);
|
setAcceptLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---- Open work detail as fullscreen modal ---- */
|
/* ---- Navigate to work route ---- */
|
||||||
const [workModalProcessId, setWorkModalProcessId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const goToWork = (processId: string) => {
|
const goToWork = (processId: string) => {
|
||||||
setWorkModalProcessId(processId);
|
try {
|
||||||
|
const existing = JSON.parse(
|
||||||
|
sessionStorage.getItem(POP_NEW_PROD_STATE_KEY) || "{}",
|
||||||
|
);
|
||||||
|
sessionStorage.setItem(
|
||||||
|
POP_NEW_PROD_STATE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
...existing,
|
||||||
|
activeTab,
|
||||||
|
scrollY: window.scrollY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
router.push(`/COMPANY_7/pop/production/work/${processId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---- Restore activeTab + scrollY on mount ---- */
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(POP_NEW_PROD_STATE_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (
|
||||||
|
parsed.activeTab === "all" ||
|
||||||
|
parsed.activeTab === "acceptable" ||
|
||||||
|
parsed.activeTab === "in_progress" ||
|
||||||
|
parsed.activeTab === "waiting" ||
|
||||||
|
parsed.activeTab === "completed"
|
||||||
|
) {
|
||||||
|
setActiveTab(parsed.activeTab);
|
||||||
|
}
|
||||||
|
if (typeof parsed.scrollY === "number") {
|
||||||
|
requestAnimationFrame(() => window.scrollTo(0, parsed.scrollY));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* ---- Open process detail modal ---- */
|
/* ---- Open process detail modal ---- */
|
||||||
const openDetailModal = (proc: WorkOrderProcess) => {
|
const openDetailModal = (proc: WorkOrderProcess) => {
|
||||||
const wi = instructionMap[proc.wo_id];
|
const wi = instructionMap[proc.wo_id];
|
||||||
@@ -1201,19 +1044,19 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
(!proc.batch_id && !p.batch_id) ||
|
(!proc.batch_id && !p.batch_id) ||
|
||||||
(proc.batch_id && p.batch_id === proc.batch_id)
|
(proc.batch_id && p.batch_id === proc.batch_id)
|
||||||
))
|
))
|
||||||
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
|
.sort((a, b) => a.seq_no - b.seq_no);
|
||||||
|
|
||||||
const totalQty = wi ? wi.qty : parseInt(proc.plan_qty || "0", 10);
|
const totalQty = wi ? wi.qty : proc.plan_qty;
|
||||||
|
|
||||||
const steps: ProcessStep[] = siblings.map((s) => {
|
const steps: ProcessStep[] = siblings.map((s) => {
|
||||||
const sInput = parseInt(s.input_qty || "0", 10);
|
const sInput = s.input_qty;
|
||||||
const sGood = parseInt(s.good_qty || "0", 10);
|
const sGood = s.good_qty;
|
||||||
const sDefect = parseInt(s.defect_qty || "0", 10);
|
const sDefect = s.defect_qty;
|
||||||
const sPlan = parseInt(s.plan_qty || "0", 10);
|
const sPlan = s.plan_qty;
|
||||||
// Available = plan - input (simplified)
|
// Available = plan - input (simplified)
|
||||||
const avail = Math.max(0, sPlan - sInput);
|
const avail = Math.max(0, sPlan - sInput);
|
||||||
return {
|
return {
|
||||||
no: parseInt(s.seq_no, 10),
|
no: s.seq_no,
|
||||||
name: s.process_name || s.process_code,
|
name: s.process_name || s.process_code,
|
||||||
code: s.process_code,
|
code: s.process_code,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
@@ -1229,7 +1072,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
const hasReworks = allProcesses.some(
|
const hasReworks = allProcesses.some(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.wo_id === proc.wo_id &&
|
p.wo_id === proc.wo_id &&
|
||||||
(p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"),
|
isReworkProcess(p),
|
||||||
);
|
);
|
||||||
|
|
||||||
setDetailModal({
|
setDetailModal({
|
||||||
@@ -1242,107 +1085,29 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---- Helper: get split order label (접수 #N) ---- */
|
/* ---- Helper: get previous process display info (name + progress) ---- */
|
||||||
const splitOrderMap = useMemo(() => {
|
|
||||||
// 같은 wo_id + seq_no를 가진 SPLIT들을 그룹화하여 순서 부여
|
|
||||||
const groups: Record<string, WorkOrderProcess[]> = {};
|
|
||||||
for (const proc of allProcesses) {
|
|
||||||
if (!proc.parent_process_id) continue; // 마스터 행은 제외
|
|
||||||
if (proc.status !== "in_progress" && proc.status !== "completed")
|
|
||||||
continue;
|
|
||||||
const key = `${proc.wo_id}__${proc.seq_no}`;
|
|
||||||
if (!groups[key]) groups[key] = [];
|
|
||||||
groups[key].push(proc);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Record<string, { order: number; total: number }> = {};
|
|
||||||
for (const key of Object.keys(groups)) {
|
|
||||||
const splits = groups[key];
|
|
||||||
if (splits.length <= 1) continue; // 1개면 순서 표시 불필요
|
|
||||||
// accepted_at 기준 정렬 (없으면 started_at, 그마저 없으면 id)
|
|
||||||
splits.sort((a, b) => {
|
|
||||||
const ta = a.accepted_at
|
|
||||||
? new Date(a.accepted_at).getTime()
|
|
||||||
: a.started_at
|
|
||||||
? new Date(a.started_at).getTime()
|
|
||||||
: 0;
|
|
||||||
const tb = b.accepted_at
|
|
||||||
? new Date(b.accepted_at).getTime()
|
|
||||||
: b.started_at
|
|
||||||
? new Date(b.started_at).getTime()
|
|
||||||
: 0;
|
|
||||||
return ta - tb || a.id.localeCompare(b.id);
|
|
||||||
});
|
|
||||||
for (let i = 0; i < splits.length; i++) {
|
|
||||||
result[splits[i].id] = { order: i + 1, total: splits.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [allProcesses]);
|
|
||||||
|
|
||||||
/* ---- Helper: get previous process info ---- */
|
|
||||||
const getPrevProcessInfo = (proc: WorkOrderProcess) => {
|
const getPrevProcessInfo = (proc: WorkOrderProcess) => {
|
||||||
const siblings = (processesByWo[proc.wo_id] || [])
|
const siblings = (processesByWo[proc.wo_id] || [])
|
||||||
.filter((p) => !p.parent_process_id && (
|
.filter(
|
||||||
// 같은 batch_id끼리만 형제 (다중 품목 구분)
|
(p) =>
|
||||||
(!proc.batch_id && !p.batch_id) ||
|
!p.parent_process_id &&
|
||||||
(proc.batch_id && p.batch_id === proc.batch_id)
|
((!proc.batch_id && !p.batch_id) ||
|
||||||
))
|
(proc.batch_id && p.batch_id === proc.batch_id)),
|
||||||
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
|
)
|
||||||
|
.sort((a, b) => a.seq_no - b.seq_no);
|
||||||
|
|
||||||
const currentIdx = siblings.findIndex((p) => p.id === proc.id);
|
const currentIdx = siblings.findIndex((p) => p.id === proc.id);
|
||||||
if (currentIdx <= 0)
|
if (currentIdx <= 0)
|
||||||
return {
|
return {
|
||||||
prevGoodQty: null as number | null,
|
|
||||||
prevProcessName: null as string | null,
|
prevProcessName: null as string | null,
|
||||||
prevProgressPct: null as number | null,
|
prevProgressPct: null as number | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const prev = siblings[currentIdx - 1];
|
const prev = siblings[currentIdx - 1];
|
||||||
const prevGood = parseInt(prev.good_qty || "0", 10);
|
const prevGood = prev.good_qty;
|
||||||
const prevPlan = parseInt(prev.plan_qty || "0", 10);
|
const prevPlan = prev.plan_qty;
|
||||||
const prevPct = prevPlan > 0 ? Math.round((prevGood / prevPlan) * 100) : 0;
|
const prevPct = prevPlan > 0 ? Math.round((prevGood / prevPlan) * 100) : 0;
|
||||||
// 앞공정에서 리워크로 완료된 양품 수량
|
|
||||||
const prevSeqNo = prev.seq_no;
|
|
||||||
const reworkGoodFromPrev = allProcesses
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.wo_id === proc.wo_id &&
|
|
||||||
p.seq_no === prevSeqNo &&
|
|
||||||
p.parent_process_id &&
|
|
||||||
p.status === "completed" &&
|
|
||||||
(p.is_rework === "Y" ||
|
|
||||||
p.is_rework === "true" ||
|
|
||||||
p.is_rework === "1"),
|
|
||||||
)
|
|
||||||
.reduce((sum, p) => sum + parseInt(p.good_qty || "0", 10), 0);
|
|
||||||
// 현재 공정에서 이미 리워크로 접수된 수량
|
|
||||||
const reworkConsumedHere = allProcesses
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.wo_id === proc.wo_id &&
|
|
||||||
p.seq_no === proc.seq_no &&
|
|
||||||
p.parent_process_id &&
|
|
||||||
(p.is_rework === "Y" ||
|
|
||||||
p.is_rework === "true" ||
|
|
||||||
p.is_rework === "1"),
|
|
||||||
)
|
|
||||||
.reduce((sum, p) => sum + parseInt(p.input_qty || "0", 10), 0);
|
|
||||||
const reworkAvailableQty = Math.max(
|
|
||||||
0,
|
|
||||||
reworkGoodFromPrev - reworkConsumedHere,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 접수가능 수량을 초과하지 않도록 제한
|
|
||||||
const inputQtyNum = parseInt(proc.input_qty || "0", 10);
|
|
||||||
const actualAvailable = Math.max(0, prevGood - inputQtyNum);
|
|
||||||
const clampedReworkAvailable = Math.min(
|
|
||||||
reworkAvailableQty,
|
|
||||||
actualAvailable,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prevGoodQty: prevGood,
|
|
||||||
prevProcessName: prev.process_name || prev.process_code,
|
prevProcessName: prev.process_name || prev.process_code,
|
||||||
prevProgressPct:
|
prevProgressPct:
|
||||||
prev.status === "in_progress"
|
prev.status === "in_progress"
|
||||||
@@ -1350,7 +1115,6 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
: prev.status === "completed"
|
: prev.status === "completed"
|
||||||
? 100
|
? 100
|
||||||
: null,
|
: null,
|
||||||
reworkAvailableQty: clampedReworkAvailable,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1448,7 +1212,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
};
|
};
|
||||||
const diff = (order[a.status] ?? 2) - (order[b.status] ?? 2);
|
const diff = (order[a.status] ?? 2) - (order[b.status] ?? 2);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
return parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10);
|
return a.seq_no - b.seq_no;
|
||||||
})
|
})
|
||||||
.map((proc) => {
|
.map((proc) => {
|
||||||
const wi = instructionMap[proc.wo_id];
|
const wi = instructionMap[proc.wo_id];
|
||||||
@@ -1460,14 +1224,11 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
(proc.batch_id && p.batch_id === proc.batch_id)
|
(proc.batch_id && p.batch_id === proc.batch_id)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const planQty = parseInt(proc.plan_qty || "0", 10);
|
const planQty = proc.plan_qty;
|
||||||
const goodQty = parseInt(proc.good_qty || "0", 10);
|
const goodQty = proc.good_qty;
|
||||||
const defectQty = parseInt(proc.defect_qty || "0", 10);
|
const defectQty = proc.defect_qty;
|
||||||
const inputQty = parseInt(proc.input_qty || "0", 10);
|
const inputQty = proc.input_qty;
|
||||||
const isRework =
|
const isRework = isReworkProcess(proc);
|
||||||
proc.is_rework === "Y" ||
|
|
||||||
proc.is_rework === "true" ||
|
|
||||||
proc.is_rework === "1";
|
|
||||||
const borderLeft = isRework
|
const borderLeft = isRework
|
||||||
? "border-l-orange-500"
|
? "border-l-orange-500"
|
||||||
: BORDER_LEFT_COLOR[proc.status] || "border-l-gray-300";
|
: BORDER_LEFT_COLOR[proc.status] || "border-l-gray-300";
|
||||||
@@ -1492,22 +1253,30 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
: 0;
|
: 0;
|
||||||
const remainQty = Math.max(0, inputQty - goodQty - defectQty);
|
const remainQty = Math.max(0, inputQty - goodQty - defectQty);
|
||||||
const prevInfo = getPrevProcessInfo(proc);
|
const prevInfo = getPrevProcessInfo(proc);
|
||||||
|
const prevGoodQtyNum =
|
||||||
|
proc.prev_good_qty != null
|
||||||
|
? Number(proc.prev_good_qty)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Calculate available qty for acceptable
|
// Server-computed: available qty
|
||||||
const availableQty = isRework
|
const availableQty = Number(proc.available_qty ?? 0);
|
||||||
? inputQty // 리워크 카드는 input_qty 자체가 접수 대상
|
|
||||||
: prevInfo.prevGoodQty !== null
|
|
||||||
? Math.max(0, prevInfo.prevGoodQty - inputQty)
|
|
||||||
: Math.max(0, planQty - inputQty);
|
|
||||||
|
|
||||||
// Additional available for in_progress
|
// Additional available for in_progress
|
||||||
const additionalAvailable = Math.max(0, planQty - inputQty);
|
const additionalAvailable = Math.max(0, planQty - inputQty);
|
||||||
|
|
||||||
// Split order label
|
// Split order label (서버 계산 — split_no / split_total)
|
||||||
const splitInfo = splitOrderMap[proc.id];
|
const splitInfo =
|
||||||
|
proc.split_no != null &&
|
||||||
|
proc.split_total != null &&
|
||||||
|
Number(proc.split_total) > 1
|
||||||
|
? {
|
||||||
|
order: Number(proc.split_no),
|
||||||
|
total: Number(proc.split_total),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
// 합류 불가 리워크 감지: 접수가능 물량이 전부 리워크일 때
|
// 합류 불가 리워크 감지: 접수가능 물량이 전부 리워크일 때
|
||||||
const reworkQtyAvail = prevInfo.reworkAvailableQty || 0;
|
const reworkQtyAvail = Number(proc.rework_available_qty ?? 0);
|
||||||
const normalAvail = availableQty - reworkQtyAvail;
|
const normalAvail = availableQty - reworkQtyAvail;
|
||||||
const isReworkOnly =
|
const isReworkOnly =
|
||||||
!isRework &&
|
!isRework &&
|
||||||
@@ -1530,9 +1299,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
(p) =>
|
(p) =>
|
||||||
p.wo_id === proc.wo_id &&
|
p.wo_id === proc.wo_id &&
|
||||||
!p.parent_process_id &&
|
!p.parent_process_id &&
|
||||||
(p.is_rework === "Y" ||
|
isReworkProcess(p),
|
||||||
p.is_rework === "true" ||
|
|
||||||
p.is_rework === "1"),
|
|
||||||
);
|
);
|
||||||
const sortedReworks = [...reworkMasters].sort((a, b) => {
|
const sortedReworks = [...reworkMasters].sort((a, b) => {
|
||||||
const da = a.created_date
|
const da = a.created_date
|
||||||
@@ -1557,7 +1324,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
originProcessName =
|
originProcessName =
|
||||||
origin.process_name || origin.process_code;
|
origin.process_name || origin.process_code;
|
||||||
originProcessCode = origin.process_code;
|
originProcessCode = origin.process_code;
|
||||||
originDefectQty = parseInt(origin.defect_qty || "0", 10);
|
originDefectQty = origin.defect_qty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1672,9 +1439,9 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
) : proc.status === "acceptable" ? (
|
) : proc.status === "acceptable" ? (
|
||||||
<AcceptableCardBody
|
<AcceptableCardBody
|
||||||
planQty={planQty}
|
planQty={planQty}
|
||||||
prevGoodQty={prevInfo.prevGoodQty}
|
prevGoodQty={prevGoodQtyNum}
|
||||||
availableQty={availableQty}
|
availableQty={availableQty}
|
||||||
reworkAvailableQty={prevInfo.reworkAvailableQty}
|
reworkAvailableQty={reworkQtyAvail}
|
||||||
/>
|
/>
|
||||||
) : proc.status === "in_progress" ? (
|
) : proc.status === "in_progress" ? (
|
||||||
<InProgressCardBody
|
<InProgressCardBody
|
||||||
@@ -1693,7 +1460,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
currentProcessName={
|
currentProcessName={
|
||||||
proc.process_name || proc.process_code
|
proc.process_name || proc.process_code
|
||||||
}
|
}
|
||||||
currentSeqNo={parseInt(proc.seq_no, 10)}
|
currentSeqNo={proc.seq_no}
|
||||||
/>
|
/>
|
||||||
) : proc.status === "completed" ? (
|
) : proc.status === "completed" ? (
|
||||||
<CompletedCardBody
|
<CompletedCardBody
|
||||||
@@ -1756,7 +1523,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{proc.status === "in_progress" &&
|
{proc.status === "in_progress" &&
|
||||||
parseInt(proc.total_production_qty || "0", 10) === 0 &&
|
proc.total_production_qty === 0 &&
|
||||||
proc.parent_process_id && (
|
proc.parent_process_id && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1802,27 +1569,6 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
showReworkHistory={detailModal.showReworkHistory}
|
showReworkHistory={detailModal.showReworkHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Fullscreen Work Modal */}
|
|
||||||
{workModalProcessId && (
|
|
||||||
<FullscreenWorkModal
|
|
||||||
processId={workModalProcessId}
|
|
||||||
myProcesses={allProcesses.filter(
|
|
||||||
(p) =>
|
|
||||||
p.parent_process_id &&
|
|
||||||
p.accepted_by === currentUserId &&
|
|
||||||
p.status === "in_progress",
|
|
||||||
)}
|
|
||||||
instructionMap={instructionMap}
|
|
||||||
itemNameMap={itemNameMap}
|
|
||||||
multiBatchInfo={multiBatchInfo}
|
|
||||||
onSwitch={(id) => setWorkModalProcessId(id)}
|
|
||||||
onClose={() => {
|
|
||||||
setWorkModalProcessId(null);
|
|
||||||
refetch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cancel accept confirm modal */}
|
{/* Cancel accept confirm modal */}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={cancelConfirm.open}
|
open={cancelConfirm.open}
|
||||||
@@ -1841,11 +1587,11 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
|||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
refetch();
|
refetch();
|
||||||
} else {
|
} else {
|
||||||
alert(res.data?.message || "취소 실패");
|
toast.error(res.data?.message || "취소 실패");
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const e2 = err as { response?: { data?: { message?: string } } };
|
const e2 = err as { response?: { data?: { message?: string } } };
|
||||||
alert(e2.response?.data?.message || "취소 중 오류");
|
toast.error(e2.response?.data?.message || "취소 중 오류");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCancel={() => setCancelConfirm({ open: false, processId: "" })}
|
onCancel={() => setCancelConfirm({ open: false, processId: "" })}
|
||||||
|
|||||||
+5
-4
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
@@ -251,7 +252,7 @@ export function MaterialInputSection({ processId }: { processId: string }) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (inputs.length === 0) {
|
if (inputs.length === 0) {
|
||||||
alert("투입 수량을 입력해주세요.");
|
toast.warning("투입 수량을 입력해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,17 +263,17 @@ export function MaterialInputSection({ processId }: { processId: string }) {
|
|||||||
inputs,
|
inputs,
|
||||||
});
|
});
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
alert(res.data.message || "투입 완료");
|
toast.success(res.data.message || "투입 완료");
|
||||||
setInputValues({});
|
setInputValues({});
|
||||||
const inputRes = await apiClient.get(
|
const inputRes = await apiClient.get(
|
||||||
`/pop/production/material-inputs/${processId}`,
|
`/pop/production/material-inputs/${processId}`,
|
||||||
);
|
);
|
||||||
setInputted(inputRes.data?.data || []);
|
setInputted(inputRes.data?.data || []);
|
||||||
} else {
|
} else {
|
||||||
alert(res.data?.message || "투입 실패");
|
toast.error(res.data?.message || "투입 실패");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("투입 중 오류");
|
toast.error("투입 중 오류");
|
||||||
}
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* POP 공정실행 화면 타입/정규화 유틸 (Phase D)
|
||||||
|
*
|
||||||
|
* 목적:
|
||||||
|
* - 서버 응답의 stringly-typed 필드(수량 string, is_rework "Y"/"true"/"1")를
|
||||||
|
* UI 레이어에서 number/boolean으로 일원화
|
||||||
|
* - normalize는 useProcessData 입구 1곳에서만 수행, 이후 컴포넌트는 View만 취급
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 서버 응답 raw row (일부 string + Phase C number 혼재) */
|
||||||
|
export interface WorkOrderProcessRaw {
|
||||||
|
id: string;
|
||||||
|
wo_id: string;
|
||||||
|
seq_no: string | number;
|
||||||
|
process_code: string;
|
||||||
|
process_name: string;
|
||||||
|
status: string;
|
||||||
|
result_status?: string;
|
||||||
|
plan_qty?: string | number | null;
|
||||||
|
input_qty?: string | number | null;
|
||||||
|
good_qty?: string | number | null;
|
||||||
|
defect_qty?: string | number | null;
|
||||||
|
concession_qty?: string | number | null;
|
||||||
|
total_production_qty?: string | number | null;
|
||||||
|
parent_process_id?: string | null;
|
||||||
|
is_rework?: string | boolean | null;
|
||||||
|
rework_source_id?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
accepted_by?: string | null;
|
||||||
|
accepted_at?: string | null;
|
||||||
|
created_date?: string | null;
|
||||||
|
batch_id?: string | null;
|
||||||
|
equipment_code?: string | null;
|
||||||
|
// Phase C 서버 계산 필드
|
||||||
|
available_qty?: string | number | null;
|
||||||
|
prev_good_qty?: string | number | null;
|
||||||
|
my_input_qty?: string | number | null;
|
||||||
|
rework_available_qty?: string | number | null;
|
||||||
|
split_no?: string | number | null;
|
||||||
|
split_total?: string | number | null;
|
||||||
|
batch_count?: string | number | null;
|
||||||
|
batch_list?: string[] | null;
|
||||||
|
batch_index?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI 컴포넌트가 사용하는 정규화 View — 수량 number, 플래그 boolean */
|
||||||
|
export interface WorkOrderProcessView {
|
||||||
|
id: string;
|
||||||
|
wo_id: string;
|
||||||
|
seq_no: number;
|
||||||
|
process_code: string;
|
||||||
|
process_name: string;
|
||||||
|
status: "acceptable" | "waiting" | "in_progress" | "completed";
|
||||||
|
result_status: string;
|
||||||
|
|
||||||
|
// 수량 (모두 number, null 폴백 0)
|
||||||
|
plan_qty: number;
|
||||||
|
input_qty: number;
|
||||||
|
good_qty: number;
|
||||||
|
defect_qty: number;
|
||||||
|
concession_qty: number;
|
||||||
|
total_production_qty: number;
|
||||||
|
|
||||||
|
// 리워크
|
||||||
|
is_rework: boolean;
|
||||||
|
rework_source_id: string | null;
|
||||||
|
|
||||||
|
// 계층 / 배치
|
||||||
|
parent_process_id: string | null;
|
||||||
|
batch_id: string | null;
|
||||||
|
|
||||||
|
// 타이밍
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
accepted_at: string | null;
|
||||||
|
accepted_by: string | null;
|
||||||
|
created_date: string | null;
|
||||||
|
|
||||||
|
// 설비
|
||||||
|
equipment_code: string | null;
|
||||||
|
|
||||||
|
// Phase C 서버 계산 필드
|
||||||
|
available_qty: number;
|
||||||
|
prev_good_qty: number | null;
|
||||||
|
my_input_qty: number;
|
||||||
|
rework_available_qty: number;
|
||||||
|
split_no: number | null;
|
||||||
|
split_total: number | null;
|
||||||
|
batch_count: number;
|
||||||
|
batch_list: string[] | null;
|
||||||
|
batch_index: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 범용 Yes 판정 — "Y"/"true"/"1" (대소문자 유연) 또는 boolean true */
|
||||||
|
export function isYes(v: unknown): boolean {
|
||||||
|
if (v === true) return true;
|
||||||
|
if (typeof v !== "string") return false;
|
||||||
|
const s = v.trim().toLowerCase();
|
||||||
|
return s === "y" || s === "true" || s === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 리워크 행 여부 — is_rework만 보는 래퍼 (Phase D 집약 대상) */
|
||||||
|
export function isReworkProcess(
|
||||||
|
row: { is_rework?: string | boolean | null } | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!row) return false;
|
||||||
|
return isYes(row.is_rework);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수량 정규화 — string/null → number, NaN 방지 */
|
||||||
|
function toInt(v: unknown): number {
|
||||||
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
|
if (v == null) return 0;
|
||||||
|
const n = parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** nullable 숫자 정규화 — null 유지 */
|
||||||
|
function toNullableInt(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
||||||
|
const n = parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 raw row → 정규화 View
|
||||||
|
* - 수량 문자열 → number (NaN 방지)
|
||||||
|
* - is_rework → boolean
|
||||||
|
* - seq_no → number
|
||||||
|
* - Phase C 계산 필드 number 통일
|
||||||
|
*/
|
||||||
|
export function normalizeWorkOrderProcess(
|
||||||
|
raw: WorkOrderProcessRaw,
|
||||||
|
): WorkOrderProcessView {
|
||||||
|
return {
|
||||||
|
id: String(raw.id || ""),
|
||||||
|
wo_id: String(raw.wo_id || ""),
|
||||||
|
seq_no: toInt(raw.seq_no),
|
||||||
|
process_code: String(raw.process_code || ""),
|
||||||
|
process_name: String(raw.process_name || ""),
|
||||||
|
status: (raw.status as WorkOrderProcessView["status"]) || "waiting",
|
||||||
|
result_status: String(raw.result_status || ""),
|
||||||
|
plan_qty: toInt(raw.plan_qty),
|
||||||
|
input_qty: toInt(raw.input_qty),
|
||||||
|
good_qty: toInt(raw.good_qty),
|
||||||
|
defect_qty: toInt(raw.defect_qty),
|
||||||
|
concession_qty: toInt(raw.concession_qty),
|
||||||
|
total_production_qty: toInt(raw.total_production_qty),
|
||||||
|
is_rework: isYes(raw.is_rework),
|
||||||
|
rework_source_id: raw.rework_source_id ?? null,
|
||||||
|
parent_process_id: raw.parent_process_id ?? null,
|
||||||
|
batch_id: raw.batch_id ?? null,
|
||||||
|
started_at: raw.started_at ?? null,
|
||||||
|
completed_at: raw.completed_at ?? null,
|
||||||
|
accepted_at: raw.accepted_at ?? null,
|
||||||
|
accepted_by: raw.accepted_by ?? null,
|
||||||
|
created_date: raw.created_date ?? null,
|
||||||
|
equipment_code: raw.equipment_code ?? null,
|
||||||
|
// Phase C
|
||||||
|
available_qty: toInt(raw.available_qty),
|
||||||
|
prev_good_qty: toNullableInt(raw.prev_good_qty),
|
||||||
|
my_input_qty: toInt(raw.my_input_qty),
|
||||||
|
rework_available_qty: toInt(raw.rework_available_qty),
|
||||||
|
split_no: toNullableInt(raw.split_no),
|
||||||
|
split_total: toNullableInt(raw.split_total),
|
||||||
|
batch_count: toInt(raw.batch_count),
|
||||||
|
batch_list: Array.isArray(raw.batch_list) ? raw.batch_list : null,
|
||||||
|
batch_index: raw.batch_index ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import {
|
||||||
|
normalizeWorkOrderProcess,
|
||||||
|
type WorkOrderProcessRaw,
|
||||||
|
type WorkOrderProcessView,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types (WorkOrderList.tsx와 동일 스키마) */
|
/* Types (WorkOrderList.tsx와 동일 스키마) */
|
||||||
@@ -28,31 +33,8 @@ export interface WorkInstruction {
|
|||||||
worker: string;
|
worker: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderProcess {
|
/** Phase D: 정규화 View re-export (기존 타입명 유지 호환) */
|
||||||
id: string;
|
export type WorkOrderProcess = WorkOrderProcessView;
|
||||||
wo_id: string;
|
|
||||||
seq_no: string;
|
|
||||||
process_code: string;
|
|
||||||
process_name: string;
|
|
||||||
status: "acceptable" | "waiting" | "in_progress" | "completed";
|
|
||||||
plan_qty: string;
|
|
||||||
input_qty: string;
|
|
||||||
good_qty: string;
|
|
||||||
defect_qty: string;
|
|
||||||
concession_qty: string;
|
|
||||||
total_production_qty: string;
|
|
||||||
parent_process_id: string | null;
|
|
||||||
is_rework: string;
|
|
||||||
rework_source_id: string | null;
|
|
||||||
result_status: string;
|
|
||||||
started_at: string | null;
|
|
||||||
completed_at: string | null;
|
|
||||||
accepted_by?: string;
|
|
||||||
accepted_at?: string | null;
|
|
||||||
created_date?: string;
|
|
||||||
batch_id?: string | null;
|
|
||||||
equipment_code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessMng {
|
export interface ProcessMng {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -110,7 +92,7 @@ export function useProcessData() {
|
|||||||
|
|
||||||
const [wiRes, procRes, pmRes, eqRes] = await Promise.all([
|
const [wiRes, procRes, pmRes, eqRes] = await Promise.all([
|
||||||
apiClient.get("/work-instruction/list"),
|
apiClient.get("/work-instruction/list"),
|
||||||
dataApi.getTableData("work_order_process", { size: 1000 }),
|
apiClient.get("/pop/production/processes"),
|
||||||
dataApi.getTableData("process_mng", { size: 500 }),
|
dataApi.getTableData("process_mng", { size: 500 }),
|
||||||
dataApi.getTableData("equipment_mng", { size: 500 }),
|
dataApi.getTableData("equipment_mng", { size: 500 }),
|
||||||
]);
|
]);
|
||||||
@@ -153,7 +135,8 @@ export function useProcessData() {
|
|||||||
setInstructions(wiData);
|
setInstructions(wiData);
|
||||||
setItemNameMap(newItemNameMap);
|
setItemNameMap(newItemNameMap);
|
||||||
setItemTypeMap(newItemTypeMap);
|
setItemTypeMap(newItemTypeMap);
|
||||||
setAllProcesses((procRes.data ?? []) as WorkOrderProcess[]);
|
const rawRows: WorkOrderProcessRaw[] = procRes.data?.data ?? [];
|
||||||
|
setAllProcesses(rawRows.map(normalizeWorkOrderProcess));
|
||||||
setProcessList((pmRes.data ?? []) as ProcessMng[]);
|
setProcessList((pmRes.data ?? []) as ProcessMng[]);
|
||||||
setEquipmentList((eqRes.data ?? []) as EquipmentMng[]);
|
setEquipmentList((eqRes.data ?? []) as EquipmentMng[]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { SupplierModal, type Supplier, type PartnerSourceConfig } from "../../_components/inbound/SupplierModal";
|
import { SupplierModal, type Supplier, type PartnerSourceConfig } from "../../_components/inbound/SupplierModal";
|
||||||
import { EquipmentModal, type EquipmentItem } from "../../_components/common/EquipmentModal";
|
import { EquipmentModal, type EquipmentItem } from "../../_components/common/EquipmentModal";
|
||||||
@@ -30,6 +30,8 @@ const PROCESS_SOURCE: PartnerSourceConfig = {
|
|||||||
const COLS_OPTIONS: ColsKey[] = [1, 2, 3];
|
const COLS_OPTIONS: ColsKey[] = [1, 2, 3];
|
||||||
/** 신규 POP 전용 카드 열 localStorage 키 (구 POP `workorder-card-cols`와 독립) */
|
/** 신규 POP 전용 카드 열 localStorage 키 (구 POP `workorder-card-cols`와 독립) */
|
||||||
const POP_NEW_COLS_KEY = "pop-new-workorder-cols";
|
const POP_NEW_COLS_KEY = "pop-new-workorder-cols";
|
||||||
|
/** 신규 POP 공정실행 목록 상태 sessionStorage 키 (라우트 이동 후 복원) */
|
||||||
|
const POP_NEW_PROD_STATE_KEY = "pop-new-production-process-state";
|
||||||
const DEFAULT_COLS: ColsKey = 2;
|
const DEFAULT_COLS: ColsKey = 2;
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -69,6 +71,72 @@ export default function ProductionProcessPage() {
|
|||||||
const [equipments, setEquipments] = useState<EquipmentItem[]>([]);
|
const [equipments, setEquipments] = useState<EquipmentItem[]>([]);
|
||||||
const [equipmentLoading, setEquipmentLoading] = useState(false);
|
const [equipmentLoading, setEquipmentLoading] = useState(false);
|
||||||
|
|
||||||
|
/* 마운트 시 selectedProcess 복원 (selectedEquipment는 equipments 로딩 완료 후 2단계 복원) */
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(POP_NEW_PROD_STATE_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.selectedProcess && parsed.selectedProcess.customer_code) {
|
||||||
|
setSelectedProcess(parsed.selectedProcess as Supplier);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* selectedProcess 변경 시 sessionStorage 머지 저장 */
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(
|
||||||
|
sessionStorage.getItem(POP_NEW_PROD_STATE_KEY) || "{}",
|
||||||
|
);
|
||||||
|
sessionStorage.setItem(
|
||||||
|
POP_NEW_PROD_STATE_KEY,
|
||||||
|
JSON.stringify({ ...existing, selectedProcess }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [selectedProcess]);
|
||||||
|
|
||||||
|
/* selectedEquipment 변경 시 sessionStorage 머지 저장 */
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(
|
||||||
|
sessionStorage.getItem(POP_NEW_PROD_STATE_KEY) || "{}",
|
||||||
|
);
|
||||||
|
sessionStorage.setItem(
|
||||||
|
POP_NEW_PROD_STATE_KEY,
|
||||||
|
JSON.stringify({ ...existing, selectedEquipment }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [selectedEquipment]);
|
||||||
|
|
||||||
|
/* equipments 로딩 완료 후 selectedEquipment 1회 복원 */
|
||||||
|
const equipRestoredRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (equipRestoredRef.current) return;
|
||||||
|
if (equipmentLoading) return;
|
||||||
|
if (equipments.length === 0) return;
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(POP_NEW_PROD_STATE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
const savedEq = parsed.selectedEquipment as EquipmentItem | null;
|
||||||
|
if (savedEq && savedEq.id) {
|
||||||
|
const found = equipments.find((e) => e.id === savedEq.id);
|
||||||
|
if (found) setSelectedEquipment(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
equipRestoredRef.current = true;
|
||||||
|
}, [equipmentLoading, equipments]);
|
||||||
|
|
||||||
/* 공정 변경 시 해당 공정 등록 설비 조회 */
|
/* 공정 변경 시 해당 공정 등록 설비 조회 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProcess?.customer_code) {
|
if (!selectedProcess?.customer_code) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { ProcessWork } from "../../../_components/production/ProcessWork";
|
||||||
|
|
||||||
|
export default function WorkPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const processId = params.processId as string;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* ===== Back + Title ===== */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/COMPANY_7/pop/production/process")}
|
||||||
|
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>
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">공정 작업</h1>
|
||||||
|
</div>
|
||||||
|
<ProcessWork processId={processId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { AuthProvider } from "@/contexts/AuthContext";
|
import { AuthProvider } from "@/contexts/AuthContext";
|
||||||
import { MenuProvider } from "@/contexts/MenuContext";
|
import { MenuProvider } from "@/contexts/MenuContext";
|
||||||
import { MessengerProvider } from "@/contexts/MessengerContext";
|
import { MessengerProvider } from "@/contexts/MessengerContext";
|
||||||
@@ -8,6 +11,13 @@ import { MessengerFAB } from "@/components/messenger/MessengerFAB";
|
|||||||
import { MessengerModal } from "@/components/messenger/MessengerModal";
|
import { MessengerModal } from "@/components/messenger/MessengerModal";
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isPop = pathname.includes("/pop/") || pathname.endsWith("/pop");
|
||||||
|
|
||||||
|
if (isPop) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<MenuProvider>
|
<MenuProvider>
|
||||||
|
|||||||
@@ -523,6 +523,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||||||
// POP 모드 진입 핸들러
|
// POP 모드 진입 핸들러
|
||||||
const handlePopModeClick = async () => {
|
const handlePopModeClick = async () => {
|
||||||
try {
|
try {
|
||||||
|
// PC → POP 전환 시 전체화면 적용
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 전체화면 미지원 또는 거부 시 무시
|
||||||
|
}
|
||||||
|
|
||||||
const response = await menuApi.getPopMenus();
|
const response = await menuApi.getPopMenus();
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const { childMenus, landingMenu } = response.data;
|
const { childMenus, landingMenu } = response.data;
|
||||||
|
|||||||
@@ -4,17 +4,17 @@
|
|||||||
* DB(cart_items 테이블) <-> 로컬 상태를 동기화하고 변경사항(dirty)을 감지한다.
|
* DB(cart_items 테이블) <-> 로컬 상태를 동기화하고 변경사항(dirty)을 감지한다.
|
||||||
*
|
*
|
||||||
* 동작 방식:
|
* 동작 방식:
|
||||||
* 1. 마운트 시 DB에서 해당 screen_id + user_id의 장바구니를 로드
|
* 1. 마운트 시 DB에서 해당 카테고리(inbound/outbound)의 장바구니를 로드
|
||||||
* 2. addItem/removeItem/updateItem은 로컬 상태만 변경 (DB 미반영, dirty 상태)
|
* 2. addItem/removeItem/updateItem은 로컬 상태만 변경 (DB 미반영, dirty 상태)
|
||||||
* 3. saveToDb 호출 시 로컬 상태를 DB에 일괄 반영 (추가/수정/삭제)
|
* 3. saveToDb 호출 시 로컬 상태를 DB에 일괄 반영 (추가/수정/삭제)
|
||||||
* 4. isDirty = 로컬 상태와 DB 마지막 로드 상태의 차이 존재 여부
|
* 4. isDirty = 로컬 상태와 DB 마지막 로드 상태의 차이 존재 여부
|
||||||
*
|
*
|
||||||
* 사용 예시:
|
* 사용 예시:
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* const cart = useCartSync("SCR-001", "item_info");
|
* const cart = useCartSync("inbound");
|
||||||
*
|
*
|
||||||
* // 품목 추가 (로컬만, DB 미반영)
|
* // 품목 추가 (로컬만, DB 미반영) — sourceTable은 항목별로 전달
|
||||||
* cart.addItem({ row, quantity: 10 }, "D1710008");
|
* cart.addItem({ row, quantity: 10 }, "D1710008", "purchase_detail");
|
||||||
*
|
*
|
||||||
* // DB 저장 (pop-icon 확인 모달에서 호출)
|
* // DB 저장 (pop-icon 확인 모달에서 호출)
|
||||||
* await cart.saveToDb();
|
* await cart.saveToDb();
|
||||||
@@ -40,6 +40,8 @@ export interface CartChanges {
|
|||||||
toDelete: (string | number)[];
|
toDelete: (string | number)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CartCategory = "inbound" | "outbound";
|
||||||
|
|
||||||
export interface UseCartSyncReturn {
|
export interface UseCartSyncReturn {
|
||||||
cartItems: CartItemWithId[];
|
cartItems: CartItemWithId[];
|
||||||
savedItems: CartItemWithId[];
|
savedItems: CartItemWithId[];
|
||||||
@@ -48,7 +50,7 @@ export interface UseCartSyncReturn {
|
|||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|
||||||
addItem: (item: CartItem, rowKey: string) => void;
|
addItem: (item: CartItem, rowKey: string, sourceTable?: string) => void;
|
||||||
removeItem: (rowKey: string) => void;
|
removeItem: (rowKey: string) => void;
|
||||||
updateItemQuantity: (
|
updateItemQuantity: (
|
||||||
rowKey: string,
|
rowKey: string,
|
||||||
@@ -111,8 +113,9 @@ function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
|
|||||||
|
|
||||||
function cartItemToDbRecord(
|
function cartItemToDbRecord(
|
||||||
item: CartItemWithId,
|
item: CartItemWithId,
|
||||||
screenId: string,
|
cartType: string,
|
||||||
selectedColumns?: string[],
|
selectedColumns?: string[],
|
||||||
|
screenId?: string,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const rowData =
|
const rowData =
|
||||||
selectedColumns && selectedColumns.length > 0
|
selectedColumns && selectedColumns.length > 0
|
||||||
@@ -121,9 +124,8 @@ function cartItemToDbRecord(
|
|||||||
)
|
)
|
||||||
: item.row;
|
: item.row;
|
||||||
|
|
||||||
return {
|
const record: Record<string, unknown> = {
|
||||||
cart_type: "pop",
|
cart_type: cartType,
|
||||||
screen_id: screenId,
|
|
||||||
source_table: item.sourceTable,
|
source_table: item.sourceTable,
|
||||||
row_key: item.rowKey,
|
row_key: item.rowKey,
|
||||||
row_data: JSON.stringify(rowData),
|
row_data: JSON.stringify(rowData),
|
||||||
@@ -136,6 +138,11 @@ function cartItemToDbRecord(
|
|||||||
status: item.status,
|
status: item.status,
|
||||||
memo: item.memo || "",
|
memo: item.memo || "",
|
||||||
};
|
};
|
||||||
|
// 레거시 모드: screen_id 포함
|
||||||
|
if (screenId) {
|
||||||
|
record.screen_id = screenId;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== dirty check: 두 배열의 내용이 동일한지 비교 =====
|
// ===== dirty check: 두 배열의 내용이 동일한지 비교 =====
|
||||||
@@ -157,32 +164,48 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
|
|||||||
|
|
||||||
// ===== 훅 본체 =====
|
// ===== 훅 본체 =====
|
||||||
|
|
||||||
|
// 오버로드: 카테고리 기반 (신규) + 레거시 screen_id 기반 (PopCardListComponent 등)
|
||||||
|
export function useCartSync(category: CartCategory): UseCartSyncReturn;
|
||||||
|
export function useCartSync(screenId: string, sourceTable: string): UseCartSyncReturn;
|
||||||
export function useCartSync(
|
export function useCartSync(
|
||||||
screenId: string,
|
categoryOrScreenId: string,
|
||||||
sourceTable: string,
|
sourceTable?: string,
|
||||||
): UseCartSyncReturn {
|
): UseCartSyncReturn {
|
||||||
|
// 레거시 호출 감지: 2번째 인자가 있으면 구 시그니처 (screen_id 기반)
|
||||||
|
const isLegacy = sourceTable !== undefined;
|
||||||
|
const cartTypeValue = isLegacy ? "pop" : `pop_${categoryOrScreenId}`;
|
||||||
|
const screenIdValue = isLegacy ? categoryOrScreenId : undefined;
|
||||||
|
const legacySourceTable = isLegacy ? sourceTable : undefined;
|
||||||
|
|
||||||
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
||||||
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||||
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
|
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const screenIdRef = useRef(screenId);
|
const categoryRef = useRef(categoryOrScreenId);
|
||||||
const sourceTableRef = useRef(sourceTable);
|
categoryRef.current = categoryOrScreenId;
|
||||||
screenIdRef.current = screenId;
|
const cartTypeRef = useRef(cartTypeValue);
|
||||||
sourceTableRef.current = sourceTable;
|
cartTypeRef.current = cartTypeValue;
|
||||||
|
const legacySourceTableRef = useRef(legacySourceTable);
|
||||||
|
legacySourceTableRef.current = legacySourceTable;
|
||||||
|
|
||||||
// ----- DB에서 장바구니 로드 -----
|
// ----- DB에서 장바구니 로드 -----
|
||||||
const loadFromDb = useCallback(async () => {
|
const loadFromDb = useCallback(async () => {
|
||||||
if (!screenId || !sourceTable) return;
|
if (!categoryOrScreenId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const filters: Record<string, string> = {
|
||||||
|
cart_type: cartTypeValue,
|
||||||
|
status: "in_cart",
|
||||||
|
};
|
||||||
|
// 레거시: screen_id로 필터링
|
||||||
|
if (screenIdValue) {
|
||||||
|
filters.screen_id = screenIdValue;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await dataApi.getTableData("cart_items", {
|
const result = await dataApi.getTableData("cart_items", {
|
||||||
size: 500,
|
size: 500,
|
||||||
filters: {
|
filters,
|
||||||
screen_id: screenId,
|
|
||||||
cart_type: "pop",
|
|
||||||
status: "in_cart",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = (result.data || []).map(dbRowToCartItem);
|
const items = (result.data || []).map(dbRowToCartItem);
|
||||||
@@ -194,7 +217,7 @@ export function useCartSync(
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [screenId, sourceTable]);
|
}, [categoryOrScreenId, cartTypeValue, screenIdValue]);
|
||||||
|
|
||||||
// 마운트 시 자동 로드
|
// 마운트 시 자동 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -213,7 +236,7 @@ export function useCartSync(
|
|||||||
|
|
||||||
// ----- 로컬 조작 (DB 미반영) -----
|
// ----- 로컬 조작 (DB 미반영) -----
|
||||||
|
|
||||||
const addItem = useCallback((item: CartItem, rowKey: string) => {
|
const addItem = useCallback((item: CartItem, rowKey: string, sourceTable?: string) => {
|
||||||
setCartItems((prev) => {
|
setCartItems((prev) => {
|
||||||
const exists = prev.find((i) => i.rowKey === rowKey);
|
const exists = prev.find((i) => i.rowKey === rowKey);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -232,7 +255,7 @@ export function useCartSync(
|
|||||||
const newItem: CartItemWithId = {
|
const newItem: CartItemWithId = {
|
||||||
...item,
|
...item,
|
||||||
cartId: undefined,
|
cartId: undefined,
|
||||||
sourceTable: sourceTableRef.current,
|
sourceTable: sourceTable || legacySourceTableRef.current || "",
|
||||||
rowKey,
|
rowKey,
|
||||||
status: "in_cart",
|
status: "in_cart",
|
||||||
_origin: "local",
|
_origin: "local",
|
||||||
@@ -293,7 +316,8 @@ export function useCartSync(
|
|||||||
// ----- diff 계산 (백엔드 전송용) -----
|
// ----- diff 계산 (백엔드 전송용) -----
|
||||||
const getChanges = useCallback(
|
const getChanges = useCallback(
|
||||||
(selectedColumns?: string[]): CartChanges => {
|
(selectedColumns?: string[]): CartChanges => {
|
||||||
const currentScreenId = screenIdRef.current;
|
const currentCartType = cartTypeRef.current;
|
||||||
|
const currentScreenId = isLegacy ? categoryRef.current : undefined;
|
||||||
|
|
||||||
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||||
const toDeleteItems = savedItems.filter(
|
const toDeleteItems = savedItems.filter(
|
||||||
@@ -306,7 +330,6 @@ export function useCartSync(
|
|||||||
if (!c.cartId) return false;
|
if (!c.cartId) return false;
|
||||||
const saved = savedMap.get(c.rowKey);
|
const saved = savedMap.get(c.rowKey);
|
||||||
if (!saved) return false;
|
if (!saved) return false;
|
||||||
// row JSON 비교 (검사 결과 등 포함)
|
|
||||||
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
|
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
|
||||||
return (
|
return (
|
||||||
c.quantity !== saved.quantity ||
|
c.quantity !== saved.quantity ||
|
||||||
@@ -318,11 +341,11 @@ export function useCartSync(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
toCreate: toCreateItems.map((item) =>
|
toCreate: toCreateItems.map((item) =>
|
||||||
cartItemToDbRecord(item, currentScreenId, selectedColumns),
|
cartItemToDbRecord(item, currentCartType, selectedColumns, currentScreenId),
|
||||||
),
|
),
|
||||||
toUpdate: toUpdateItems.map((item) => ({
|
toUpdate: toUpdateItems.map((item) => ({
|
||||||
id: item.cartId,
|
id: item.cartId,
|
||||||
...cartItemToDbRecord(item, currentScreenId, selectedColumns),
|
...cartItemToDbRecord(item, currentCartType, selectedColumns, currentScreenId),
|
||||||
})),
|
})),
|
||||||
toDelete: toDeleteItems.map((item) => item.cartId!),
|
toDelete: toDeleteItems.map((item) => item.cartId!),
|
||||||
};
|
};
|
||||||
@@ -335,7 +358,8 @@ export function useCartSync(
|
|||||||
async (selectedColumns?: string[]): Promise<boolean> => {
|
async (selectedColumns?: string[]): Promise<boolean> => {
|
||||||
setSyncStatus("saving");
|
setSyncStatus("saving");
|
||||||
try {
|
try {
|
||||||
const currentScreenId = screenIdRef.current;
|
const currentCartType = cartTypeRef.current;
|
||||||
|
const currentScreenId = isLegacy ? categoryRef.current : undefined;
|
||||||
|
|
||||||
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
|
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
|
||||||
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||||
@@ -375,8 +399,9 @@ export function useCartSync(
|
|||||||
for (const item of toCreate) {
|
for (const item of toCreate) {
|
||||||
const record = cartItemToDbRecord(
|
const record = cartItemToDbRecord(
|
||||||
item,
|
item,
|
||||||
currentScreenId,
|
currentCartType,
|
||||||
selectedColumns,
|
selectedColumns,
|
||||||
|
currentScreenId,
|
||||||
);
|
);
|
||||||
// cart_items.id는 NOT NULL + 자동생성 없음 → UUID 직접 생성
|
// cart_items.id는 NOT NULL + 자동생성 없음 → UUID 직접 생성
|
||||||
const recordWithId = { id: crypto.randomUUID(), ...record };
|
const recordWithId = { id: crypto.randomUUID(), ...record };
|
||||||
@@ -386,8 +411,9 @@ export function useCartSync(
|
|||||||
for (const item of toUpdate) {
|
for (const item of toUpdate) {
|
||||||
const record = cartItemToDbRecord(
|
const record = cartItemToDbRecord(
|
||||||
item,
|
item,
|
||||||
currentScreenId,
|
currentCartType,
|
||||||
selectedColumns,
|
selectedColumns,
|
||||||
|
currentScreenId,
|
||||||
);
|
);
|
||||||
promises.push(
|
promises.push(
|
||||||
dataApi.updateRecord("cart_items", item.cartId!, record),
|
dataApi.updateRecord("cart_items", item.cartId!, record),
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ export const useLogin = () => {
|
|||||||
if (isPopMode) {
|
if (isPopMode) {
|
||||||
const popPath = result.data?.popLandingPath;
|
const popPath = result.data?.popLandingPath;
|
||||||
if (popPath) {
|
if (popPath) {
|
||||||
|
// POP 모드 로그인 시 전체화면 전환 시도
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 전체화면 미지원 또는 거부 시 무시
|
||||||
|
}
|
||||||
router.push(popPath);
|
router.push(popPath);
|
||||||
} else {
|
} else {
|
||||||
setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요.");
|
setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요.");
|
||||||
|
|||||||
@@ -97,6 +97,28 @@ export async function deletePkgUnit(id: string) {
|
|||||||
return res.data as { success: boolean; message?: string };
|
return res.data as { success: boolean; message?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 품목별 포장단위 조회 API ---
|
||||||
|
|
||||||
|
export interface PkgUnitByItem {
|
||||||
|
id: string;
|
||||||
|
pkg_code: string;
|
||||||
|
pkg_name: string;
|
||||||
|
pkg_type: string;
|
||||||
|
status: string;
|
||||||
|
width_mm: number | null;
|
||||||
|
length_mm: number | null;
|
||||||
|
height_mm: number | null;
|
||||||
|
self_weight_kg: number | null;
|
||||||
|
max_load_kg: number | null;
|
||||||
|
volume_l: number | null;
|
||||||
|
pkg_qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPkgUnitsByItem(itemNumber: string) {
|
||||||
|
const res = await apiClient.get(`/packaging/pkg-units-by-item/${encodeURIComponent(itemNumber)}`);
|
||||||
|
return res.data as { success: boolean; data: PkgUnitByItem[] };
|
||||||
|
}
|
||||||
|
|
||||||
// --- 포장단위 매칭품목 API ---
|
// --- 포장단위 매칭품목 API ---
|
||||||
|
|
||||||
export async function getPkgUnitItems(pkgCode: string) {
|
export async function getPkgUnitItems(pkgCode: string) {
|
||||||
@@ -114,6 +136,29 @@ export async function deletePkgUnitItem(id: string) {
|
|||||||
return res.data as { success: boolean; message?: string };
|
return res.data as { success: boolean; message?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 포장코드별 적재함 조회 API ---
|
||||||
|
|
||||||
|
export interface LoadingUnitByPkg {
|
||||||
|
id: string;
|
||||||
|
loading_code: string;
|
||||||
|
loading_name: string;
|
||||||
|
loading_type: string;
|
||||||
|
status: string;
|
||||||
|
width_mm: number | null;
|
||||||
|
length_mm: number | null;
|
||||||
|
height_mm: number | null;
|
||||||
|
self_weight_kg: number | null;
|
||||||
|
max_load_kg: number | null;
|
||||||
|
max_stack: number | null;
|
||||||
|
max_load_qty: number;
|
||||||
|
load_method: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoadingUnitsByPkg(pkgCode: string) {
|
||||||
|
const res = await apiClient.get(`/packaging/loading-units-by-pkg/${encodeURIComponent(pkgCode)}`);
|
||||||
|
return res.data as { success: boolean; data: LoadingUnitByPkg[] };
|
||||||
|
}
|
||||||
|
|
||||||
// --- 적재함 API ---
|
// --- 적재함 API ---
|
||||||
|
|
||||||
export async function getLoadingUnits() {
|
export async function getLoadingUnits() {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"components/screen/ScreenDesigner_old.tsx",
|
"components/screen/ScreenDesigner_old.tsx",
|
||||||
"components/admin/dashboard/widgets/yard-3d/Yard3DCanvas_NEW.tsx",
|
"components/admin/dashboard/widgets/yard-3d/Yard3DCanvas_NEW.tsx",
|
||||||
"components/flow/FlowDataListModal.tsx",
|
"components/flow/FlowDataListModal.tsx",
|
||||||
"test-scenarios"
|
"test-scenarios/**",
|
||||||
|
"app/test-type-safety/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user