WIP: POP + packaging 작업 중

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kmh
2026-04-22 14:55:28 +09:00
parent f117534d6c
commit 4ed4d5f66e
30 changed files with 1705 additions and 907 deletions
+17 -2
View File
@@ -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);
+67
View File
@@ -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="닫기"
>
&times;
</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: "" })}
@@ -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>
);
}
+10
View File
@@ -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>
+9
View File
@@ -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;
+57 -31
View File
@@ -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),
+8
View File
@@ -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 화면을 설정해달라고 요청하세요.");
+45
View File
@@ -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() {
+2 -1
View File
@@ -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/**"
] ]
} }