WIP: POP + packaging 작업 중
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -227,10 +227,24 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
// 새로운 JWT 토큰 발급 (company_code만 변경)
|
||||
// 전환 대상 회사명 조회
|
||||
let targetCompanyName: string | undefined;
|
||||
if (companyCode === "*") {
|
||||
targetCompanyName = "공통";
|
||||
} else {
|
||||
const { query: dbQuery } = await import("../database/db");
|
||||
const companyRows = await dbQuery<{ company_name: string }>(
|
||||
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||
[companyCode.trim()]
|
||||
);
|
||||
targetCompanyName = companyRows[0]?.company_name || companyCode.trim();
|
||||
}
|
||||
|
||||
// 새로운 JWT 토큰 발급 (company_code + company_name 변경)
|
||||
const newPersonBean: PersonBean = {
|
||||
...currentUser,
|
||||
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
|
||||
companyCode: companyCode.trim(),
|
||||
companyName: targetCompanyName,
|
||||
};
|
||||
|
||||
const newToken = JwtUtils.generateToken(newPersonBean);
|
||||
@@ -355,6 +369,7 @@ export class AuthController {
|
||||
deptName: dbUserInfo.deptName || "",
|
||||
companyCode: 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 토큰 우선
|
||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||
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
|
||||
// ──────────────────────────────────────────────
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -155,8 +155,13 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
.json({ success: false, message: "입고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
// 첫 번째 아이템에서 inbound_type 추출 (헤더용)
|
||||
const inboundType = items[0].inbound_type || null;
|
||||
// 헤더용 inbound_type: 단일이면 그 값, 혼합이면 "혼합입고"
|
||||
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;
|
||||
|
||||
await client.query("BEGIN");
|
||||
@@ -331,12 +336,11 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
);
|
||||
}
|
||||
|
||||
// 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지
|
||||
if (
|
||||
item.inbound_type === "구매입고" &&
|
||||
item.source_id &&
|
||||
item.source_table === "purchase_order_mng"
|
||||
) {
|
||||
// 2c. source_table 기준 소스 데이터 업데이트 (이중 입고 방지)
|
||||
const srcTable = item.source_table;
|
||||
const srcId = item.source_id;
|
||||
|
||||
if (srcTable === "purchase_order_mng" && srcId) {
|
||||
await client.query(
|
||||
`UPDATE purchase_order_mng
|
||||
SET received_qty = CAST(
|
||||
@@ -354,17 +358,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
END,
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[item.inbound_qty || 0, item.source_id, companyCode],
|
||||
[item.inbound_qty || 0, srcId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
// 구매입고인 경우 purchase_detail 품목별 입고수량 업데이트
|
||||
if (
|
||||
item.inbound_type === "구매입고" &&
|
||||
item.source_id &&
|
||||
item.source_table === "purchase_detail"
|
||||
) {
|
||||
// 1. 해당 purchase_detail의 received_qty 누적 업데이트
|
||||
} else if (srcTable === "purchase_detail" && srcId) {
|
||||
await client.query(
|
||||
`UPDATE purchase_detail SET
|
||||
received_qty = CAST(
|
||||
@@ -377,17 +373,15 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
),
|
||||
updated_date = NOW()
|
||||
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(
|
||||
`SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`,
|
||||
[item.source_id, companyCode],
|
||||
[srcId, companyCode],
|
||||
);
|
||||
if (detailInfo.rows.length > 0) {
|
||||
const purchaseNo = detailInfo.rows[0].purchase_no;
|
||||
// 잔량 있는 디테일이 있는지 확인
|
||||
const unreceived = await client.query(
|
||||
`SELECT id FROM purchase_detail
|
||||
WHERE purchase_no = $1 AND company_code = $2
|
||||
@@ -419,6 +413,28 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
[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_date,
|
||||
si.partner_id,
|
||||
si.partner_id AS partner_code,
|
||||
COALESCE(cm.customer_name, si.partner_id) AS partner_name,
|
||||
si.status AS instruction_status,
|
||||
sid.item_code,
|
||||
sid.item_name,
|
||||
@@ -1056,6 +1074,9 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) {
|
||||
JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id
|
||||
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}
|
||||
ORDER BY si.instruction_date DESC, si.instruction_no
|
||||
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) {
|
||||
try {
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
|
||||
getPkgUnitsByItem,
|
||||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||
getLoadingUnitsByPkg,
|
||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||
getItemsByDivision, getGeneralItems,
|
||||
} from "../controllers/packagingController";
|
||||
@@ -18,6 +20,9 @@ router.post("/pkg-units", createPkgUnit);
|
||||
router.put("/pkg-units/:id", updatePkgUnit);
|
||||
router.delete("/pkg-units/:id", deletePkgUnit);
|
||||
|
||||
// 품목별 포장단위 조회
|
||||
router.get("/pkg-units-by-item/:itemNumber", getPkgUnitsByItem);
|
||||
|
||||
// 포장단위 매칭품목
|
||||
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
|
||||
router.post("/pkg-unit-items", createPkgUnitItem);
|
||||
@@ -29,6 +34,9 @@ router.post("/loading-units", createLoadingUnit);
|
||||
router.put("/loading-units/:id", updateLoadingUnit);
|
||||
router.delete("/loading-units/:id", deleteLoadingUnit);
|
||||
|
||||
// 포장코드별 적재함 조회
|
||||
router.get("/loading-units-by-pkg/:pkgCode", getLoadingUnitsByPkg);
|
||||
|
||||
// 적재함 포장구성
|
||||
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
||||
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
saveMaterialInput,
|
||||
getMaterialInputs,
|
||||
getChecklistItems,
|
||||
getProcessList,
|
||||
} from "../controllers/popProductionController";
|
||||
|
||||
const router = Router();
|
||||
@@ -51,5 +52,6 @@ router.get("/bom-materials/:processId", getBomMaterials);
|
||||
router.post("/material-input", saveMaterialInput);
|
||||
router.get("/material-inputs/:processId", getMaterialInputs);
|
||||
router.get("/checklist-items/:processId", getChecklistItems);
|
||||
router.get("/processes", getProcessList);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -28,6 +28,9 @@ router.get("/source/shipments", receivingController.getShipments);
|
||||
// 소스 데이터: 품목 (기타입고)
|
||||
router.get("/source/items", receivingController.getItems);
|
||||
|
||||
// 소스 데이터: 생산실적 (생산입고)
|
||||
router.get("/source/production-results", receivingController.getProductionResults);
|
||||
|
||||
// 입고 등록
|
||||
router.post("/", receivingController.create);
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
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. 개요
|
||||
@@ -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
|
||||
- `frontend/app/(main)/layout.tsx` 수정
|
||||
- `"use client"` 추가
|
||||
@@ -527,6 +557,43 @@ frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유
|
||||
- 타입 체크(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차)
|
||||
- **출고관리 화면 API 연동 (UI 껍데기 → 실연동, 입고관리 로직 포팅)**
|
||||
- `_components/outbound/OutboundManage.tsx` 전면 rewrite
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { COLOR_MAP, type PopColor } from "./theme";
|
||||
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
color?: PopColor;
|
||||
size?: Size;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
sm: "min-w-[96px] min-h-[40px] text-sm px-3",
|
||||
md: "min-w-[144px] min-h-[48px] text-base px-4",
|
||||
lg: "min-w-[200px] min-h-[56px] text-lg px-6",
|
||||
};
|
||||
|
||||
const COMMON =
|
||||
"rounded-xl font-semibold text-white shadow-md transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2";
|
||||
|
||||
const PopButton = forwardRef<HTMLButtonElement, Props>(
|
||||
({ color = "blue", size = "md", icon, children, className, ...rest }, ref) => {
|
||||
const tokens = COLOR_MAP[color];
|
||||
const classes = [
|
||||
COMMON,
|
||||
SIZE_CLASSES[size],
|
||||
tokens.buttonBg,
|
||||
tokens.buttonBgHover,
|
||||
tokens.ring,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button ref={ref} className={classes} {...rest}>
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PopButton.displayName = "PopButton";
|
||||
|
||||
export default PopButton;
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { HTMLAttributes, forwardRef } from "react";
|
||||
import { COLOR_MAP, type PopColor } from "./theme";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
selected?: boolean;
|
||||
color?: PopColor;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
const BASE =
|
||||
"w-full min-h-[180px] rounded-2xl bg-white border shadow-sm p-4 flex flex-col transition-all";
|
||||
|
||||
const PopCard = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{ selected = false, color = "blue", interactive = true, className, ...rest },
|
||||
ref
|
||||
) => {
|
||||
const tokens = COLOR_MAP[color];
|
||||
|
||||
const classes = [
|
||||
BASE,
|
||||
selected ? tokens.border : "border-gray-200",
|
||||
interactive ? "hover:shadow-md hover:border-gray-300 cursor-pointer" : "",
|
||||
selected ? tokens.ringSelected : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return <div ref={ref} className={classes} {...rest} />;
|
||||
}
|
||||
);
|
||||
|
||||
PopCard.displayName = "PopCard";
|
||||
|
||||
export default PopCard;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
type Cols = 1 | 2 | 3 | 4;
|
||||
|
||||
interface ColProfile {
|
||||
base?: Cols;
|
||||
md?: Cols;
|
||||
lg?: Cols;
|
||||
xl?: Cols;
|
||||
"2xl"?: Cols;
|
||||
}
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
cols?: ColProfile;
|
||||
gap?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const BASE_COLS: Record<Cols, string> = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-2",
|
||||
3: "grid-cols-3",
|
||||
4: "grid-cols-4",
|
||||
};
|
||||
const MD_COLS: Record<Cols, string> = {
|
||||
1: "md:grid-cols-1",
|
||||
2: "md:grid-cols-2",
|
||||
3: "md:grid-cols-3",
|
||||
4: "md:grid-cols-4",
|
||||
};
|
||||
const LG_COLS: Record<Cols, string> = {
|
||||
1: "lg:grid-cols-1",
|
||||
2: "lg:grid-cols-2",
|
||||
3: "lg:grid-cols-3",
|
||||
4: "lg:grid-cols-4",
|
||||
};
|
||||
const XL_COLS: Record<Cols, string> = {
|
||||
1: "xl:grid-cols-1",
|
||||
2: "xl:grid-cols-2",
|
||||
3: "xl:grid-cols-3",
|
||||
4: "xl:grid-cols-4",
|
||||
};
|
||||
const XXL_COLS: Record<Cols, string> = {
|
||||
1: "2xl:grid-cols-1",
|
||||
2: "2xl:grid-cols-2",
|
||||
3: "2xl:grid-cols-3",
|
||||
4: "2xl:grid-cols-4",
|
||||
};
|
||||
|
||||
const GAP: Record<"sm" | "md" | "lg", string> = {
|
||||
sm: "gap-3",
|
||||
md: "gap-4",
|
||||
lg: "gap-6",
|
||||
};
|
||||
|
||||
export default function PopCardGrid({
|
||||
cols = { base: 1, md: 2, xl: 3 },
|
||||
gap = "md",
|
||||
className,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { base = 1, md, lg, xl, "2xl": xxl } = cols;
|
||||
|
||||
const classes = [
|
||||
"grid w-full",
|
||||
GAP[gap],
|
||||
BASE_COLS[base],
|
||||
md != null ? MD_COLS[md] : "",
|
||||
lg != null ? LG_COLS[lg] : "",
|
||||
xl != null ? XL_COLS[xl] : "",
|
||||
xxl != null ? XXL_COLS[xxl] : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return <div className={classes} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
|
||||
type Size = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
size?: Size;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
sm: "w-[min(90vw,420px)] max-h-[80vh]",
|
||||
md: "w-[min(90vw,640px)] max-h-[85vh]",
|
||||
lg: "w-[min(95vw,900px)] max-h-[90vh]",
|
||||
xl: "w-[min(98vw,1200px)] max-h-[95vh]",
|
||||
};
|
||||
|
||||
export default function PopModal({
|
||||
open,
|
||||
onClose,
|
||||
size = "md",
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
}: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`${SIZE_CLASSES[size]} bg-white rounded-2xl shadow-xl flex flex-col`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(title != null || !hideCloseButton) && (
|
||||
<header className="flex items-center justify-between px-5 py-4 border-b border-gray-200 shrink-0">
|
||||
{title != null && (
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
)}
|
||||
{!hideCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto w-10 h-10 flex items-center justify-center rounded-xl text-gray-500 hover:bg-gray-100 text-xl font-medium"
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
<main className="flex-1 overflow-y-auto p-4">{children}</main>
|
||||
{footer && (
|
||||
<footer className="border-t border-gray-200 p-4 shrink-0">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// POP 9-color palette. 키는 부위별 완성 리터럴 — JIT 스캔 대상.
|
||||
// 동적 문자열 생성 금지(`bg-${x}` 등). 반드시 COLOR_MAP[color].부위 조회로 접근.
|
||||
|
||||
export type PopColor =
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'cyan'
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'pink'
|
||||
| 'teal'
|
||||
| 'orange'
|
||||
| 'amber';
|
||||
|
||||
export interface PopColorTokens {
|
||||
buttonBg: string;
|
||||
buttonBgHover: string;
|
||||
ring: string;
|
||||
ringSelected: string;
|
||||
text: string;
|
||||
bg50: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
export const COLOR_MAP: Record<PopColor, PopColorTokens> = {
|
||||
blue: {
|
||||
buttonBg: 'bg-gradient-to-b from-blue-400 to-blue-700',
|
||||
buttonBgHover: 'hover:from-blue-500 hover:to-blue-800',
|
||||
ring: 'focus:ring-blue-500',
|
||||
ringSelected: 'ring-2 ring-blue-500',
|
||||
text: 'text-blue-600',
|
||||
bg50: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
},
|
||||
purple: {
|
||||
buttonBg: 'bg-gradient-to-b from-purple-400 to-purple-700',
|
||||
buttonBgHover: 'hover:from-purple-500 hover:to-purple-800',
|
||||
ring: 'focus:ring-purple-500',
|
||||
ringSelected: 'ring-2 ring-purple-500',
|
||||
text: 'text-purple-600',
|
||||
bg50: 'bg-purple-50',
|
||||
border: 'border-purple-200',
|
||||
},
|
||||
cyan: {
|
||||
buttonBg: 'bg-gradient-to-b from-cyan-400 to-cyan-700',
|
||||
buttonBgHover: 'hover:from-cyan-500 hover:to-cyan-800',
|
||||
ring: 'focus:ring-cyan-500',
|
||||
ringSelected: 'ring-2 ring-cyan-500',
|
||||
text: 'text-cyan-600',
|
||||
bg50: 'bg-cyan-50',
|
||||
border: 'border-cyan-200',
|
||||
},
|
||||
green: {
|
||||
buttonBg: 'bg-gradient-to-b from-green-400 to-green-700',
|
||||
buttonBgHover: 'hover:from-green-500 hover:to-green-800',
|
||||
ring: 'focus:ring-green-500',
|
||||
ringSelected: 'ring-2 ring-green-500',
|
||||
text: 'text-green-600',
|
||||
bg50: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
},
|
||||
red: {
|
||||
buttonBg: 'bg-gradient-to-b from-red-400 to-red-700',
|
||||
buttonBgHover: 'hover:from-red-500 hover:to-red-800',
|
||||
ring: 'focus:ring-red-500',
|
||||
ringSelected: 'ring-2 ring-red-500',
|
||||
text: 'text-red-600',
|
||||
bg50: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
},
|
||||
pink: {
|
||||
buttonBg: 'bg-gradient-to-b from-pink-400 to-pink-700',
|
||||
buttonBgHover: 'hover:from-pink-500 hover:to-pink-800',
|
||||
ring: 'focus:ring-pink-500',
|
||||
ringSelected: 'ring-2 ring-pink-500',
|
||||
text: 'text-pink-600',
|
||||
bg50: 'bg-pink-50',
|
||||
border: 'border-pink-200',
|
||||
},
|
||||
teal: {
|
||||
buttonBg: 'bg-gradient-to-b from-teal-400 to-teal-700',
|
||||
buttonBgHover: 'hover:from-teal-500 hover:to-teal-800',
|
||||
ring: 'focus:ring-teal-500',
|
||||
ringSelected: 'ring-2 ring-teal-500',
|
||||
text: 'text-teal-600',
|
||||
bg50: 'bg-teal-50',
|
||||
border: 'border-teal-200',
|
||||
},
|
||||
orange: {
|
||||
buttonBg: 'bg-gradient-to-b from-orange-400 to-orange-700',
|
||||
buttonBgHover: 'hover:from-orange-500 hover:to-orange-800',
|
||||
ring: 'focus:ring-orange-500',
|
||||
ringSelected: 'ring-2 ring-orange-500',
|
||||
text: 'text-orange-600',
|
||||
bg50: 'bg-orange-50',
|
||||
border: 'border-orange-200',
|
||||
},
|
||||
amber: {
|
||||
buttonBg: 'bg-gradient-to-b from-amber-400 to-amber-700',
|
||||
buttonBgHover: 'hover:from-amber-500 hover:to-amber-800',
|
||||
ring: 'focus:ring-amber-500',
|
||||
ringSelected: 'ring-2 ring-amber-500',
|
||||
text: 'text-amber-600',
|
||||
bg50: 'bg-amber-50',
|
||||
border: 'border-amber-200',
|
||||
},
|
||||
};
|
||||
@@ -292,13 +292,11 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
|
||||
setPackagingOpen(true);
|
||||
};
|
||||
|
||||
const handlePackagingConfirm = (qty: number, packages: PackageEntry[]) => {
|
||||
const handlePackagingConfirm = (_qty: number, packages: PackageEntry[]) => {
|
||||
if (!packagingTarget) return;
|
||||
const finalQty = Math.min(qty, packagingTarget.remain_qty);
|
||||
|
||||
cart.updateItemQuantity(
|
||||
packagingTarget.rowKey,
|
||||
finalQty,
|
||||
packagingTarget.inbound_qty,
|
||||
undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
packages.length > 0 ? (packages as any) : undefined,
|
||||
@@ -1112,21 +1110,39 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Package info (포장 완료 시 — 클릭하면 모달 열림) === */}
|
||||
{item.packages && item.packages.length > 0 && (
|
||||
<>
|
||||
{/* === Package info (포장 등록 시 — 클릭하면 모달 열림) === */}
|
||||
{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
|
||||
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">
|
||||
<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 className="text-[11px] font-semibold text-green-600">
|
||||
{item.packages
|
||||
.reduce((s, p) => s + p.count * p.qtyPerUnit, 0)
|
||||
.toLocaleString()}{" "}
|
||||
<span
|
||||
className={`text-[11px] font-semibold ${
|
||||
isComplete ? "text-green-600" : "text-amber-600"
|
||||
}`}
|
||||
>
|
||||
{packagedQty.toLocaleString()}{" "}
|
||||
EA
|
||||
</span>
|
||||
</div>
|
||||
@@ -1145,9 +1161,17 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -1348,7 +1372,7 @@ export function InboundCartPage({ backUrl }: InboundCartPageProps) {
|
||||
setPackagingTarget(null);
|
||||
}}
|
||||
onConfirm={handlePackagingConfirm}
|
||||
maxQty={packagingTarget.remain_qty}
|
||||
maxQty={packagingTarget.inbound_qty}
|
||||
itemName={packagingTarget.item_name}
|
||||
itemNumber={packagingTarget.item_code}
|
||||
initialPackages={packagingTarget.packages}
|
||||
|
||||
@@ -31,15 +31,7 @@ interface NumberPadModalProps {
|
||||
initialPackages?: PackageEntry[];
|
||||
}
|
||||
|
||||
type Step =
|
||||
| "packaging"
|
||||
| "qty-per-pkg"
|
||||
| "pkg-count"
|
||||
| "remainder"
|
||||
| "rem-packaging"
|
||||
| "rem-qty-per-pkg"
|
||||
| "rem-pkg-count"
|
||||
| "confirm";
|
||||
type Step = "list" | "packaging" | "count";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Numpad Keys */
|
||||
@@ -73,21 +65,15 @@ export function NumberPadModal({
|
||||
itemNumber,
|
||||
initialPackages,
|
||||
}: NumberPadModalProps) {
|
||||
const [step, setStep] = useState<Step>("packaging");
|
||||
|
||||
// 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 [step, setStep] = useState<Step>("list");
|
||||
const [packages, setPackages] = useState<PackageEntry[]>([]);
|
||||
const [packageUnits, setPackageUnits] = useState<PackageUnit[]>([]);
|
||||
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 */
|
||||
useEffect(() => {
|
||||
if (!open || !itemNumber) {
|
||||
@@ -108,172 +94,158 @@ export function NumberPadModal({
|
||||
}))
|
||||
);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setPackageUnits([]); })
|
||||
.finally(() => { if (!cancelled) setPkgLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
.catch(() => {
|
||||
if (!cancelled) setPackageUnits([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPkgLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, itemNumber]);
|
||||
|
||||
/* Reset on open */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initialPackages && initialPackages.length > 0) {
|
||||
const p = initialPackages[0];
|
||||
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);
|
||||
setPkgCount("0");
|
||||
setQtyPerPkg("0");
|
||||
setRemUnit(null);
|
||||
setRemPkgCount("0");
|
||||
setRemQtyPerPkg("0");
|
||||
}
|
||||
setPackages(initialPackages ? [...initialPackages] : []);
|
||||
setStep("list");
|
||||
setSelectedUnit(null);
|
||||
setCount("0");
|
||||
setEditingIndex(null);
|
||||
}
|
||||
}, [open, initialPackages]);
|
||||
|
||||
/* Computed values */
|
||||
const pkgCountNum = parseInt(pkgCount, 10) || 0;
|
||||
const qtyPerPkgNum = parseInt(qtyPerPkg, 10) || 0;
|
||||
const primaryQty = pkgCountNum * qtyPerPkgNum;
|
||||
const totalPackagedQty = packages.reduce(
|
||||
(s, p) => s + p.count * p.qtyPerUnit,
|
||||
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 remQty = remUnit ? remPkgCountNum * remQtyPerPkgNum : 0;
|
||||
/* 편집 중이면 해당 행 수량만큼은 "사용 가능"으로 되돌려줌 */
|
||||
const editingEntry =
|
||||
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;
|
||||
const totalQty = primaryQty + remQty;
|
||||
const isOverMax = totalQty > maxQty;
|
||||
|
||||
/* Generic numpad input handler */
|
||||
/* Numpad input */
|
||||
const handleInput = useCallback(
|
||||
(key: string, setter: React.Dispatch<React.SetStateAction<string>>, max?: number) => {
|
||||
setter((prev) => {
|
||||
(key: string) => {
|
||||
setCount((prev) => {
|
||||
switch (key) {
|
||||
case "backspace":
|
||||
return prev.length <= 1 ? "0" : prev.slice(0, -1);
|
||||
case "clear":
|
||||
return "0";
|
||||
case "max":
|
||||
return String(max ?? maxQty);
|
||||
return String(maxCountForCurrent);
|
||||
default: {
|
||||
const next = prev === "0" ? key : prev + key;
|
||||
const num = parseInt(next, 10);
|
||||
if (isNaN(num)) return prev;
|
||||
if (max !== undefined) return String(Math.min(num, max));
|
||||
return next;
|
||||
return String(Math.min(num, maxCountForCurrent));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
[maxQty]
|
||||
[maxCountForCurrent]
|
||||
);
|
||||
|
||||
/* 1차 포장 handlers */
|
||||
const handleSelectPackaging = (unit: PackageUnit) => {
|
||||
/* Step handlers */
|
||||
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);
|
||||
setPkgCount("0");
|
||||
setQtyPerPkg(unit.pkg_qty ? String(unit.pkg_qty) : "0");
|
||||
setStep("qty-per-pkg");
|
||||
setCount("0");
|
||||
setStep("count");
|
||||
};
|
||||
|
||||
const handleQtyPerPkgConfirm = () => {
|
||||
if (qtyPerPkgNum <= 0) return;
|
||||
setStep("pkg-count");
|
||||
const handleCountConfirm = () => {
|
||||
if (!selectedUnit || countNum <= 0) return;
|
||||
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 = () => {
|
||||
if (pkgCountNum <= 0 || !selectedUnit) return;
|
||||
const rem = maxQty - pkgCountNum * qtyPerPkgNum;
|
||||
if (rem > 0) {
|
||||
setStep("remainder");
|
||||
} else {
|
||||
setRemUnit(null);
|
||||
setRemPkgCount("0");
|
||||
setRemQtyPerPkg("0");
|
||||
setStep("confirm");
|
||||
const handleDelete = (idx: number) => {
|
||||
setPackages((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === "count") {
|
||||
if (editingIndex !== null) {
|
||||
setStep("list");
|
||||
setSelectedUnit(null);
|
||||
setCount("0");
|
||||
setEditingIndex(null);
|
||||
} else {
|
||||
setStep("packaging");
|
||||
setSelectedUnit(null);
|
||||
setCount("0");
|
||||
}
|
||||
} 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 = () => {
|
||||
if (primaryQty <= 0 || !selectedUnit) return;
|
||||
const finalQty = Math.min(totalQty, maxQty);
|
||||
const packages: PackageEntry[] = [
|
||||
{ unit: selectedUnit, count: pkgCountNum, qtyPerUnit: qtyPerPkgNum },
|
||||
];
|
||||
if (remUnit && remQty > 0) {
|
||||
packages.push({ unit: remUnit, count: remPkgCountNum, qtyPerUnit: remQtyPerPkgNum });
|
||||
}
|
||||
if (packages.length === 0) return;
|
||||
const finalQty = Math.min(totalPackagedQty, maxQty);
|
||||
onConfirm(finalQty, packages);
|
||||
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;
|
||||
|
||||
/* Render numpad grid */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Render helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const renderNumpad = (
|
||||
currentValue: string,
|
||||
onKey: (key: string) => void,
|
||||
onConfirmStep: () => void,
|
||||
confirmLabel: string,
|
||||
confirmDisabled: boolean,
|
||||
confirmDisabled: boolean
|
||||
) => (
|
||||
<>
|
||||
<input
|
||||
@@ -292,8 +264,8 @@ export function NumberPadModal({
|
||||
key.action === "backspace" || key.action === "clear"
|
||||
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
|
||||
: key.action === "max"
|
||||
? "bg-blue-100 text-blue-700 hover:bg-blue-200 text-sm"
|
||||
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
|
||||
? "bg-blue-100 text-blue-700 hover:bg-blue-200 text-sm"
|
||||
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{key.label}
|
||||
@@ -323,7 +295,6 @@ export function NumberPadModal({
|
||||
</>
|
||||
);
|
||||
|
||||
/* Render packaging grid */
|
||||
const renderPackagingGrid = (onSelect: (unit: PackageUnit) => void) => (
|
||||
<>
|
||||
{pkgLoading ? (
|
||||
@@ -346,7 +317,9 @@ export function NumberPadModal({
|
||||
<span className="text-2xl">{unit.icon}</span>
|
||||
<span className="text-center leading-tight">{unit.label}</span>
|
||||
{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>
|
||||
))}
|
||||
@@ -355,235 +328,218 @@ export function NumberPadModal({
|
||||
</>
|
||||
);
|
||||
|
||||
/* Header color */
|
||||
const isRemStep = step.startsWith("rem") || step === "remainder";
|
||||
const headerBg = isRemStep
|
||||
? "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)"
|
||||
: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)";
|
||||
/* Header: 단계별 색상 */
|
||||
const headerBg =
|
||||
step === "count" && editingIndex !== null
|
||||
? "linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)"
|
||||
: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)";
|
||||
|
||||
const headerBadge =
|
||||
step === "count" && editingIndex !== null
|
||||
? "수정"
|
||||
: `최대 ${maxQty.toLocaleString()} EA`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<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 */}
|
||||
<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">
|
||||
{step !== "packaging" && (
|
||||
{step !== "list" && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[13px] text-white/90 bg-white/20 px-3 py-1 rounded-full">
|
||||
{isRemStep ? `나머지 ${remainder.toLocaleString()} EA`
|
||||
: `최대 ${maxQty.toLocaleString()} EA`
|
||||
}
|
||||
{headerBadge}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4">
|
||||
|
||||
{/* ====== 1차: 포장 선택 ====== */}
|
||||
{step === "packaging" && (
|
||||
{/* ====== LIST: 목록 + 추가 + 확인 ====== */}
|
||||
{step === "list" && (
|
||||
<>
|
||||
<p className="text-center text-sm font-semibold text-gray-700 mb-4">
|
||||
포장 단위를 선택하세요
|
||||
</p>
|
||||
{renderPackagingGrid(handleSelectPackaging)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ====== 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"
|
||||
>
|
||||
건너뛰기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep("rem-packaging")}
|
||||
className="flex-1 h-14 rounded-xl text-base font-bold text-white active:scale-95 transition-all"
|
||||
style={{ background: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)" }}
|
||||
>
|
||||
포장 등록
|
||||
</button>
|
||||
</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
|
||||
{/* 요약 (포장 수량 / 미포장 잔량) */}
|
||||
<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 className="text-lg font-black text-green-600" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
= {primaryQty.toLocaleString()} EA
|
||||
<p
|
||||
className="text-lg font-black text-blue-700"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* 나머지 포장 */}
|
||||
{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 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
|
||||
onClick={() => handleDelete(idx)}
|
||||
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"
|
||||
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>
|
||||
</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>
|
||||
|
||||
<p className="text-xs text-gray-400 truncate max-w-full px-2">{itemName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{/* 품목명 */}
|
||||
<p className="text-xs text-gray-400 truncate max-w-full px-1 mb-3 text-center">
|
||||
{itemName}
|
||||
</p>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setStep("packaging")}
|
||||
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"
|
||||
onClick={openAddFlow}
|
||||
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
|
||||
onClick={handleFinalConfirm}
|
||||
className="flex-1 h-14 rounded-xl text-base font-bold text-white active:scale-95 transition-all"
|
||||
style={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
|
||||
disabled={packages.length === 0}
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SupplierModal, type Supplier, matchChosung } from "./SupplierModal";
|
||||
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
|
||||
import { BarcodeScanModal } from "../common/BarcodeScanModal";
|
||||
import type { CartItemWithId } from "../common/useCartSync";
|
||||
import { COLOR_MAP } from "../common/theme";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -332,11 +333,7 @@ export function PurchaseInbound({ cart, onCartClick, saving, inboundType, source
|
||||
<button
|
||||
onClick={onCartClick}
|
||||
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"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
|
||||
boxShadow: "0 4px 12px rgba(59,130,246,0.3)",
|
||||
}}
|
||||
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)]`}
|
||||
>
|
||||
{saving ? (
|
||||
<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 */}
|
||||
<button
|
||||
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"
|
||||
style={{
|
||||
background: "linear-gradient(to bottom, #60a5fa, #2563eb)",
|
||||
boxShadow: "0 4px 12px rgba(59,130,246,0.3)",
|
||||
}}
|
||||
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)]`}
|
||||
>
|
||||
<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" />
|
||||
@@ -439,13 +432,9 @@ export function PurchaseInbound({ cart, onCartClick, saving, inboundType, source
|
||||
<button
|
||||
onClick={() => setItemScanOpen(true)}
|
||||
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 ${
|
||||
!selectedSupplier ? "opacity-40 cursor-not-allowed" : ""
|
||||
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" : "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}>
|
||||
<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);
|
||||
};
|
||||
|
||||
const handlePackagingConfirm = (qty: number, packages: PackageEntry[]) => {
|
||||
const handlePackagingConfirm = (_qty: number, packages: PackageEntry[]) => {
|
||||
if (!packagingTarget) return;
|
||||
const finalQty = Math.min(qty, packagingTarget.remain_qty);
|
||||
const finalQty = packagingTarget.outbound_qty;
|
||||
|
||||
cart.updateItemQuantity(
|
||||
packagingTarget.rowKey,
|
||||
@@ -906,20 +906,39 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Package info (포장 완료 시 — 클릭하면 모달 열림) === */}
|
||||
{item.packages && item.packages.length > 0 && (
|
||||
{/* === Package info (포장 등록 시 — 클릭하면 모달 열림) === */}
|
||||
{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
|
||||
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">
|
||||
<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 className="text-[11px] font-semibold text-green-600">
|
||||
{item.packages
|
||||
.reduce((s, p) => s + p.count * p.qtyPerUnit, 0)
|
||||
.toLocaleString()}{" "}
|
||||
<span
|
||||
className={`text-[11px] font-semibold ${
|
||||
isComplete ? "text-green-600" : "text-amber-600"
|
||||
}`}
|
||||
>
|
||||
{packagedQty.toLocaleString()}{" "}
|
||||
EA
|
||||
</span>
|
||||
</div>
|
||||
@@ -938,8 +957,17 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) {
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -1113,7 +1141,7 @@ export function OutboundCartPage({ backUrl }: OutboundCartPageProps) {
|
||||
setPackagingTarget(null);
|
||||
}}
|
||||
onConfirm={handlePackagingConfirm}
|
||||
maxQty={packagingTarget.remain_qty}
|
||||
maxQty={packagingTarget.outbound_qty}
|
||||
itemName={packagingTarget.item_name}
|
||||
itemNumber={packagingTarget.item_code}
|
||||
initialPackages={packagingTarget.packages}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface AcceptProcessModalProps {
|
||||
onConfirm: (qty: number) => void;
|
||||
maxQty: number;
|
||||
processName: string;
|
||||
seqNo: string;
|
||||
seqNo: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { usePopSettings } from "@/hooks/pop/usePopSettings";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "./DefectTypeModal";
|
||||
import { ProcessTimer, type TimerStatus } from "./ProcessTimer";
|
||||
import { MaterialInputSection } from "./sections/MaterialInputSection";
|
||||
import { isReworkProcess, type WorkOrderProcessView } from "./types";
|
||||
|
||||
/* ================================================================== */
|
||||
/* Types */
|
||||
@@ -27,22 +29,22 @@ import { MaterialInputSection } from "./sections/MaterialInputSection";
|
||||
interface ProcessData {
|
||||
id: string;
|
||||
wo_id: string;
|
||||
seq_no: string;
|
||||
seq_no: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
status: string;
|
||||
plan_qty: string;
|
||||
input_qty: string;
|
||||
good_qty: string;
|
||||
defect_qty: string;
|
||||
concession_qty: string;
|
||||
total_production_qty: string;
|
||||
plan_qty: number;
|
||||
input_qty: number;
|
||||
good_qty: number;
|
||||
defect_qty: number;
|
||||
concession_qty: number;
|
||||
total_production_qty: number;
|
||||
parent_process_id: string | null;
|
||||
result_status: string;
|
||||
result_note: string;
|
||||
started_at: string | null;
|
||||
paused_at: string | null;
|
||||
total_paused_time: string | null;
|
||||
total_paused_time: number;
|
||||
completed_at: string | null;
|
||||
actual_work_time: string | null;
|
||||
accepted_at: string | null;
|
||||
@@ -50,11 +52,52 @@ interface ProcessData {
|
||||
defect_detail: string | null;
|
||||
target_warehouse_id: string | null;
|
||||
target_location_code: string | null;
|
||||
is_rework: string;
|
||||
is_rework: boolean;
|
||||
routing_detail_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 {
|
||||
work_instruction_no: string;
|
||||
item_name: string;
|
||||
@@ -429,7 +472,8 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
size: 1,
|
||||
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) {
|
||||
setProcess(procData);
|
||||
|
||||
@@ -552,10 +596,11 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
size: 100,
|
||||
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
|
||||
.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>();
|
||||
setProcessList(
|
||||
@@ -768,9 +813,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
return {
|
||||
...prev,
|
||||
paused_at: null,
|
||||
total_paused_time: String(
|
||||
(parseInt(prev.total_paused_time || "0", 10) || 0) + pausedSec,
|
||||
),
|
||||
total_paused_time: prev.total_paused_time + pausedSec,
|
||||
};
|
||||
}
|
||||
if (action === "complete")
|
||||
@@ -798,7 +841,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
fetchProcess();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { message?: string } } };
|
||||
alert(err.response?.data?.message || "타이머 오류");
|
||||
toast.error(err.response?.data?.message || "타이머 오류");
|
||||
fetchProcess(); // 실패 시 서버 상태로 복원
|
||||
}
|
||||
};
|
||||
@@ -929,7 +972,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
alert("체크리스트 저장 실패");
|
||||
toast.error("체크리스트 저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -939,7 +982,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
|
||||
const handleSaveResult = async () => {
|
||||
if (productionQty <= 0) {
|
||||
alert("생산수량을 입력해주세요.");
|
||||
toast.warning("생산수량을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -967,16 +1010,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
setResultNote("");
|
||||
loadHistory();
|
||||
if (d?.status === "completed") {
|
||||
alert("모든 수량이 완료되어 자동 확정되었습니다.");
|
||||
toast.success("모든 수량이 완료되어 자동 확정되었습니다.");
|
||||
} else {
|
||||
alert("실적이 저장되었습니다.");
|
||||
toast.success("실적이 저장되었습니다.");
|
||||
}
|
||||
} else {
|
||||
alert(res.data?.message || "저장 실패");
|
||||
toast.error(res.data?.message || "저장 실패");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { message?: string } } };
|
||||
alert(err.response?.data?.message || "실적 저장 중 오류");
|
||||
toast.error(err.response?.data?.message || "실적 저장 중 오류");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -994,13 +1037,13 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
});
|
||||
if (res.data?.success) {
|
||||
await fetchProcess();
|
||||
alert("실적이 확정되었습니다.");
|
||||
toast.success("실적이 확정되었습니다.");
|
||||
} else {
|
||||
alert(res.data?.message || "확정 실패");
|
||||
toast.error(res.data?.message || "확정 실패");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { message?: string } } };
|
||||
alert(err.response?.data?.message || "확정 중 오류");
|
||||
toast.error(err.response?.data?.message || "확정 중 오류");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1030,7 +1073,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
|
||||
const handleInbound = () => {
|
||||
if (!selectedWarehouse) {
|
||||
alert("창고를 선택해주세요.");
|
||||
toast.warning("창고를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
askConfirm(
|
||||
@@ -1050,9 +1093,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
);
|
||||
if (res.data?.success) {
|
||||
setInboundDone(true);
|
||||
alert(`재고 입고 완료: ${res.data.data?.qty || 0}개`);
|
||||
toast.success(`재고 입고 완료: ${res.data.data?.qty || 0}개`);
|
||||
} else {
|
||||
alert(res.data?.message || "입고 실패");
|
||||
toast.error(res.data?.message || "입고 실패");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
@@ -1061,9 +1104,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
const msg = err.response?.data?.message;
|
||||
if (err.response?.status === 409) {
|
||||
setInboundDone(true);
|
||||
alert(msg || "이미 입고 완료");
|
||||
toast.info(msg || "이미 입고 완료");
|
||||
} else {
|
||||
alert(msg || "입고 중 오류");
|
||||
toast.error(msg || "입고 중 오류");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -1082,10 +1125,10 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
0,
|
||||
);
|
||||
const goodQtyThisBatch = productionQty - totalDefectQty;
|
||||
const inputQty = parseInt(process?.input_qty || "0", 10);
|
||||
const totalProduced = parseInt(process?.total_production_qty || "0", 10);
|
||||
const accumulatedGood = parseInt(process?.good_qty || "0", 10);
|
||||
const accumulatedDefect = parseInt(process?.defect_qty || "0", 10);
|
||||
const inputQty = process?.input_qty ?? 0;
|
||||
const totalProduced = process?.total_production_qty ?? 0;
|
||||
const accumulatedGood = process?.good_qty ?? 0;
|
||||
const accumulatedDefect = process?.defect_qty ?? 0;
|
||||
const remaining = Math.max(0, inputQty - totalProduced);
|
||||
const isCompleted = process?.status === "completed";
|
||||
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">
|
||||
<span className="text-white/40 text-sm">지시</span>
|
||||
<span className="text-white font-medium text-base">
|
||||
{parseInt(process.plan_qty || "0", 10).toLocaleString()}
|
||||
{process.plan_qty.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
@@ -1203,7 +1246,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
? "진행중"
|
||||
: process.status}
|
||||
</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>
|
||||
@@ -1750,9 +1793,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
alert(res.ok ? "사진 첨부 완료" : "첨부 실패");
|
||||
res.ok ? toast.success("사진 첨부 완료") : toast.error("첨부 실패");
|
||||
} catch {
|
||||
alert("첨부 오류");
|
||||
toast.error("첨부 오류");
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
@@ -2057,9 +2100,9 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
alert(res.ok ? "사진 첨부 완료" : "첨부 실패");
|
||||
res.ok ? toast.success("사진 첨부 완료") : toast.error("첨부 실패");
|
||||
} catch {
|
||||
alert("첨부 오류");
|
||||
toast.error("첨부 오류");
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
@@ -2312,8 +2355,8 @@ function ChecklistRow({
|
||||
|
||||
if (isPassed === "N") {
|
||||
const rangeStr = `${item.lower_limit || ""}~${item.upper_limit || ""}`;
|
||||
alert(
|
||||
`⚠️ 기준 초과!\n\n입력값: ${localValue}\n허용 범위: ${rangeStr}\n\n불합격으로 기록됩니다.`,
|
||||
toast.warning(
|
||||
`기준 초과! 입력값: ${localValue} / 허용 범위: ${rangeStr} — 불합격으로 기록됩니다.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2591,12 +2634,12 @@ function ChecklistRow({
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
alert("사진 업로드 완료");
|
||||
toast.success("사진 업로드 완료");
|
||||
} else {
|
||||
alert("업로드 실패");
|
||||
toast.error("업로드 실패");
|
||||
}
|
||||
} catch {
|
||||
alert("업로드 중 오류");
|
||||
toast.error("업로드 중 오류");
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { ConfirmModal } from "../common/ConfirmModal";
|
||||
import { AcceptProcessModal } from "./AcceptProcessModal";
|
||||
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({
|
||||
@@ -83,31 +89,8 @@ interface WorkInstruction {
|
||||
worker: string;
|
||||
}
|
||||
|
||||
interface WorkOrderProcess {
|
||||
id: string;
|
||||
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;
|
||||
}
|
||||
/** Phase D: 정규화 View 재export (기존 타입명 유지) */
|
||||
type WorkOrderProcess = WorkOrderProcessView;
|
||||
|
||||
interface ProcessMng {
|
||||
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",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -374,7 +202,7 @@ function CompressedProcessSteps({
|
||||
allProcesses,
|
||||
}: {
|
||||
processes: WorkOrderProcess[];
|
||||
currentSeqNo: string;
|
||||
currentSeqNo: number;
|
||||
status: string;
|
||||
onClick?: () => void;
|
||||
batchId?: string;
|
||||
@@ -386,7 +214,7 @@ function CompressedProcessSteps({
|
||||
(!batchId && !p.batch_id) ||
|
||||
(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;
|
||||
|
||||
@@ -396,7 +224,7 @@ function CompressedProcessSteps({
|
||||
// For completed status: batch_id 기반 진행률 표시
|
||||
if (status === "completed") {
|
||||
// 같은 batch_id를 가진 SPLIT들이 어느 seq까지 완료했는지 추적
|
||||
let maxCompletedSeq = parseInt(currentSeqNo, 10); // 최소한 현재 seq까지는 완료
|
||||
let maxCompletedSeq = currentSeqNo; // 최소한 현재 seq까지는 완료
|
||||
|
||||
if (batchId && allProcesses) {
|
||||
const batchSplits = allProcesses.filter(
|
||||
@@ -406,23 +234,26 @@ function CompressedProcessSteps({
|
||||
p.status === "completed",
|
||||
);
|
||||
for (const s of batchSplits) {
|
||||
const sSeq = parseInt(s.seq_no, 10);
|
||||
const sSeq = s.seq_no;
|
||||
if (sSeq > maxCompletedSeq) maxCompletedSeq = sSeq;
|
||||
}
|
||||
}
|
||||
|
||||
const completedCount = sorted.filter(
|
||||
(p) => parseInt(p.seq_no, 10) <= maxCompletedSeq,
|
||||
(p) => p.seq_no <= maxCompletedSeq,
|
||||
).length;
|
||||
const allDone = completedCount === sorted.length;
|
||||
|
||||
return (
|
||||
<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"
|
||||
onClick={onClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
{sorted.map((proc, idx) => {
|
||||
const seqNum = parseInt(proc.seq_no, 10);
|
||||
const seqNum = proc.seq_no;
|
||||
const isDone = seqNum <= maxCompletedSeq;
|
||||
return (
|
||||
<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 ${
|
||||
isClickable ? "cursor-pointer hover:bg-gray-100" : ""
|
||||
}`}
|
||||
onClick={isClickable ? onClick : undefined}
|
||||
onClick={
|
||||
isClickable
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Collapsed before */}
|
||||
{beforeCollapsed > 0 && (
|
||||
@@ -918,10 +756,10 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
open: boolean;
|
||||
processId: string;
|
||||
processName: string;
|
||||
seqNo: string;
|
||||
seqNo: number;
|
||||
maxQty: number;
|
||||
reworkSourceId?: string;
|
||||
}>({ open: false, processId: "", processName: "", seqNo: "", maxQty: 0 });
|
||||
}>({ open: false, processId: "", processName: "", seqNo: 0, maxQty: 0 });
|
||||
const [acceptLoading, setAcceptLoading] = useState(false);
|
||||
|
||||
const [cancelConfirm, setCancelConfirm] = useState<{
|
||||
@@ -960,42 +798,23 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
return map;
|
||||
}, [allProcesses]);
|
||||
|
||||
/** 다중품목 판단: wo_id별 DISTINCT batch_id 집합 + 순번 매핑 */
|
||||
/** 서버 응답 batch_count/batch_index를 Drawer/카드 기존 구조로 어댑팅 */
|
||||
const multiBatchInfo = useMemo(() => {
|
||||
// wo_id → 고유 batch_id 목록 (마스터 행 기준)
|
||||
const woBatches: Record<string, string[]> = {};
|
||||
const map: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }> = {};
|
||||
for (const proc of allProcesses) {
|
||||
if (proc.parent_process_id) continue; // 마스터만
|
||||
if (!proc.wo_id) continue;
|
||||
if (!woBatches[proc.wo_id]) woBatches[proc.wo_id] = [];
|
||||
const total = Math.max(Number(proc.batch_count ?? 1) || 1, 1);
|
||||
const index = Number(proc.batch_index ?? 1) || 1;
|
||||
const isMulti = total > 1;
|
||||
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 = "";
|
||||
if (bid) {
|
||||
itemType = itemTypeMap[bid] || "";
|
||||
}
|
||||
if (bid) itemType = itemTypeMap[bid] || "";
|
||||
if (!itemType) {
|
||||
const wi = instructionMap[proc.wo_id];
|
||||
if (wi?.item_number) {
|
||||
itemType = itemTypeMap[wi.item_number] || "";
|
||||
}
|
||||
if (wi?.item_number) itemType = itemTypeMap[wi.item_number] || "";
|
||||
}
|
||||
info[proc.id] = { isMulti, index, total, itemType };
|
||||
map[proc.id] = { isMulti, index, total, itemType };
|
||||
}
|
||||
return info;
|
||||
return map;
|
||||
}, [allProcesses, itemTypeMap, instructionMap]);
|
||||
|
||||
const masterProcesses = useMemo(() => {
|
||||
@@ -1007,9 +826,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
!p.parent_process_id || // 마스터 행
|
||||
p.status === "in_progress" ||
|
||||
p.status === "completed" || // 분할 행
|
||||
p.is_rework === "Y" ||
|
||||
p.is_rework === "true" ||
|
||||
p.is_rework === "1"; // 재작업
|
||||
isReworkProcess(p); // 재작업
|
||||
if (include) seen.add(p.id);
|
||||
return include;
|
||||
});
|
||||
@@ -1031,10 +848,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
const filteredProcesses = useMemo(() => {
|
||||
if (selectedProcess === "__all__") return []; // 공정 미선택 시 빈 목록
|
||||
return masterProcesses.filter((proc) => {
|
||||
const isRework =
|
||||
proc.is_rework === "Y" ||
|
||||
proc.is_rework === "true" ||
|
||||
proc.is_rework === "1";
|
||||
const isRework = isReworkProcess(proc);
|
||||
const isMaster = !proc.parent_process_id;
|
||||
// 완료/진행중 탭에서는 SPLIT만 표시 (마스터 제외)
|
||||
if (
|
||||
@@ -1076,10 +890,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
/* ---- Tab counts ---- */
|
||||
const tabCounts = useMemo(() => {
|
||||
const preFiltered = masterProcesses.filter((proc) => {
|
||||
const isRework =
|
||||
proc.is_rework === "Y" ||
|
||||
proc.is_rework === "true" ||
|
||||
proc.is_rework === "1";
|
||||
const isRework = isReworkProcess(proc);
|
||||
// 재작업 카드는 공정 필터 무시 (모든 공정에서 표시)
|
||||
if (
|
||||
selectedProcess !== "__all__" &&
|
||||
@@ -1105,10 +916,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
};
|
||||
for (const proc of preFiltered) {
|
||||
const isMaster = !proc.parent_process_id;
|
||||
const isRw =
|
||||
proc.is_rework === "Y" ||
|
||||
proc.is_rework === "true" ||
|
||||
proc.is_rework === "1";
|
||||
const isRw = isReworkProcess(proc);
|
||||
// 리워크 마스터가 in_progress/completed면 SPLIT이 있으므로 카운트 제외
|
||||
if (
|
||||
isRw &&
|
||||
@@ -1136,7 +944,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
const openAcceptModal = async (
|
||||
processId: string,
|
||||
processName: string,
|
||||
seqNo: string,
|
||||
seqNo: number,
|
||||
reworkSourceId?: string,
|
||||
) => {
|
||||
try {
|
||||
@@ -1154,7 +962,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
reworkSourceId,
|
||||
});
|
||||
} catch {
|
||||
alert("접수가능량 조회 실패");
|
||||
toast.error("접수가능량 조회 실패");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1174,24 +982,59 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
setAcceptModal((m) => ({ ...m, open: false }));
|
||||
refetch();
|
||||
} else {
|
||||
alert(res.data?.message || "접수 실패");
|
||||
toast.error(res.data?.message || "접수 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.message || "접수 중 오류 발생");
|
||||
toast.error(error.response?.data?.message || "접수 중 오류 발생");
|
||||
} finally {
|
||||
setAcceptLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- Open work detail as fullscreen modal ---- */
|
||||
const [workModalProcessId, setWorkModalProcessId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/* ---- Navigate to work route ---- */
|
||||
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 ---- */
|
||||
const openDetailModal = (proc: WorkOrderProcess) => {
|
||||
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)
|
||||
))
|
||||
.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 sInput = parseInt(s.input_qty || "0", 10);
|
||||
const sGood = parseInt(s.good_qty || "0", 10);
|
||||
const sDefect = parseInt(s.defect_qty || "0", 10);
|
||||
const sPlan = parseInt(s.plan_qty || "0", 10);
|
||||
const sInput = s.input_qty;
|
||||
const sGood = s.good_qty;
|
||||
const sDefect = s.defect_qty;
|
||||
const sPlan = s.plan_qty;
|
||||
// Available = plan - input (simplified)
|
||||
const avail = Math.max(0, sPlan - sInput);
|
||||
return {
|
||||
no: parseInt(s.seq_no, 10),
|
||||
no: s.seq_no,
|
||||
name: s.process_name || s.process_code,
|
||||
code: s.process_code,
|
||||
status: s.status,
|
||||
@@ -1229,7 +1072,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
const hasReworks = allProcesses.some(
|
||||
(p) =>
|
||||
p.wo_id === proc.wo_id &&
|
||||
(p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"),
|
||||
isReworkProcess(p),
|
||||
);
|
||||
|
||||
setDetailModal({
|
||||
@@ -1242,107 +1085,29 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Helper: get split order label (접수 #N) ---- */
|
||||
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 ---- */
|
||||
/* ---- Helper: get previous process display info (name + progress) ---- */
|
||||
const getPrevProcessInfo = (proc: WorkOrderProcess) => {
|
||||
const siblings = (processesByWo[proc.wo_id] || [])
|
||||
.filter((p) => !p.parent_process_id && (
|
||||
// 같은 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));
|
||||
.filter(
|
||||
(p) =>
|
||||
!p.parent_process_id &&
|
||||
((!proc.batch_id && !p.batch_id) ||
|
||||
(proc.batch_id && p.batch_id === proc.batch_id)),
|
||||
)
|
||||
.sort((a, b) => a.seq_no - b.seq_no);
|
||||
|
||||
const currentIdx = siblings.findIndex((p) => p.id === proc.id);
|
||||
if (currentIdx <= 0)
|
||||
return {
|
||||
prevGoodQty: null as number | null,
|
||||
prevProcessName: null as string | null,
|
||||
prevProgressPct: null as number | null,
|
||||
};
|
||||
|
||||
const prev = siblings[currentIdx - 1];
|
||||
const prevGood = parseInt(prev.good_qty || "0", 10);
|
||||
const prevPlan = parseInt(prev.plan_qty || "0", 10);
|
||||
const prevGood = prev.good_qty;
|
||||
const prevPlan = prev.plan_qty;
|
||||
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 {
|
||||
prevGoodQty: prevGood,
|
||||
prevProcessName: prev.process_name || prev.process_code,
|
||||
prevProgressPct:
|
||||
prev.status === "in_progress"
|
||||
@@ -1350,7 +1115,6 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
: prev.status === "completed"
|
||||
? 100
|
||||
: null,
|
||||
reworkAvailableQty: clampedReworkAvailable,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1448,7 +1212,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
};
|
||||
const diff = (order[a.status] ?? 2) - (order[b.status] ?? 2);
|
||||
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) => {
|
||||
const wi = instructionMap[proc.wo_id];
|
||||
@@ -1460,14 +1224,11 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
(proc.batch_id && p.batch_id === proc.batch_id)
|
||||
),
|
||||
);
|
||||
const planQty = parseInt(proc.plan_qty || "0", 10);
|
||||
const goodQty = parseInt(proc.good_qty || "0", 10);
|
||||
const defectQty = parseInt(proc.defect_qty || "0", 10);
|
||||
const inputQty = parseInt(proc.input_qty || "0", 10);
|
||||
const isRework =
|
||||
proc.is_rework === "Y" ||
|
||||
proc.is_rework === "true" ||
|
||||
proc.is_rework === "1";
|
||||
const planQty = proc.plan_qty;
|
||||
const goodQty = proc.good_qty;
|
||||
const defectQty = proc.defect_qty;
|
||||
const inputQty = proc.input_qty;
|
||||
const isRework = isReworkProcess(proc);
|
||||
const borderLeft = isRework
|
||||
? "border-l-orange-500"
|
||||
: BORDER_LEFT_COLOR[proc.status] || "border-l-gray-300";
|
||||
@@ -1492,22 +1253,30 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
: 0;
|
||||
const remainQty = Math.max(0, inputQty - goodQty - defectQty);
|
||||
const prevInfo = getPrevProcessInfo(proc);
|
||||
const prevGoodQtyNum =
|
||||
proc.prev_good_qty != null
|
||||
? Number(proc.prev_good_qty)
|
||||
: null;
|
||||
|
||||
// Calculate available qty for acceptable
|
||||
const availableQty = isRework
|
||||
? inputQty // 리워크 카드는 input_qty 자체가 접수 대상
|
||||
: prevInfo.prevGoodQty !== null
|
||||
? Math.max(0, prevInfo.prevGoodQty - inputQty)
|
||||
: Math.max(0, planQty - inputQty);
|
||||
// Server-computed: available qty
|
||||
const availableQty = Number(proc.available_qty ?? 0);
|
||||
|
||||
// Additional available for in_progress
|
||||
const additionalAvailable = Math.max(0, planQty - inputQty);
|
||||
|
||||
// Split order label
|
||||
const splitInfo = splitOrderMap[proc.id];
|
||||
// Split order label (서버 계산 — split_no / split_total)
|
||||
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 isReworkOnly =
|
||||
!isRework &&
|
||||
@@ -1530,9 +1299,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
(p) =>
|
||||
p.wo_id === proc.wo_id &&
|
||||
!p.parent_process_id &&
|
||||
(p.is_rework === "Y" ||
|
||||
p.is_rework === "true" ||
|
||||
p.is_rework === "1"),
|
||||
isReworkProcess(p),
|
||||
);
|
||||
const sortedReworks = [...reworkMasters].sort((a, b) => {
|
||||
const da = a.created_date
|
||||
@@ -1557,7 +1324,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
originProcessName =
|
||||
origin.process_name || 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" ? (
|
||||
<AcceptableCardBody
|
||||
planQty={planQty}
|
||||
prevGoodQty={prevInfo.prevGoodQty}
|
||||
prevGoodQty={prevGoodQtyNum}
|
||||
availableQty={availableQty}
|
||||
reworkAvailableQty={prevInfo.reworkAvailableQty}
|
||||
reworkAvailableQty={reworkQtyAvail}
|
||||
/>
|
||||
) : proc.status === "in_progress" ? (
|
||||
<InProgressCardBody
|
||||
@@ -1693,7 +1460,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
currentProcessName={
|
||||
proc.process_name || proc.process_code
|
||||
}
|
||||
currentSeqNo={parseInt(proc.seq_no, 10)}
|
||||
currentSeqNo={proc.seq_no}
|
||||
/>
|
||||
) : proc.status === "completed" ? (
|
||||
<CompletedCardBody
|
||||
@@ -1756,7 +1523,7 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
</div>
|
||||
)}
|
||||
{proc.status === "in_progress" &&
|
||||
parseInt(proc.total_production_qty || "0", 10) === 0 &&
|
||||
proc.total_production_qty === 0 &&
|
||||
proc.parent_process_id && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -1802,27 +1569,6 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
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 */}
|
||||
<ConfirmModal
|
||||
open={cancelConfirm.open}
|
||||
@@ -1841,11 +1587,11 @@ export function WorkOrderList(props: WorkOrderListProps) {
|
||||
if (res.data?.success) {
|
||||
refetch();
|
||||
} else {
|
||||
alert(res.data?.message || "취소 실패");
|
||||
toast.error(res.data?.message || "취소 실패");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const e2 = err as { response?: { data?: { message?: string } } };
|
||||
alert(e2.response?.data?.message || "취소 중 오류");
|
||||
toast.error(e2.response?.data?.message || "취소 중 오류");
|
||||
}
|
||||
}}
|
||||
onCancel={() => setCancelConfirm({ open: false, processId: "" })}
|
||||
|
||||
+5
-4
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/* ================================================================== */
|
||||
@@ -251,7 +252,7 @@ export function MaterialInputSection({ processId }: { processId: string }) {
|
||||
}));
|
||||
|
||||
if (inputs.length === 0) {
|
||||
alert("투입 수량을 입력해주세요.");
|
||||
toast.warning("투입 수량을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,17 +263,17 @@ export function MaterialInputSection({ processId }: { processId: string }) {
|
||||
inputs,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
alert(res.data.message || "투입 완료");
|
||||
toast.success(res.data.message || "투입 완료");
|
||||
setInputValues({});
|
||||
const inputRes = await apiClient.get(
|
||||
`/pop/production/material-inputs/${processId}`,
|
||||
);
|
||||
setInputted(inputRes.data?.data || []);
|
||||
} else {
|
||||
alert(res.data?.message || "투입 실패");
|
||||
toast.error(res.data?.message || "투입 실패");
|
||||
}
|
||||
} catch {
|
||||
alert("투입 중 오류");
|
||||
toast.error("투입 중 오류");
|
||||
}
|
||||
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 { apiClient } from "@/lib/api/client";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import {
|
||||
normalizeWorkOrderProcess,
|
||||
type WorkOrderProcessRaw,
|
||||
type WorkOrderProcessView,
|
||||
} from "./types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types (WorkOrderList.tsx와 동일 스키마) */
|
||||
@@ -28,31 +33,8 @@ export interface WorkInstruction {
|
||||
worker: string;
|
||||
}
|
||||
|
||||
export interface WorkOrderProcess {
|
||||
id: string;
|
||||
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;
|
||||
}
|
||||
/** Phase D: 정규화 View re-export (기존 타입명 유지 호환) */
|
||||
export type WorkOrderProcess = WorkOrderProcessView;
|
||||
|
||||
export interface ProcessMng {
|
||||
id: string;
|
||||
@@ -110,7 +92,7 @@ export function useProcessData() {
|
||||
|
||||
const [wiRes, procRes, pmRes, eqRes] = await Promise.all([
|
||||
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("equipment_mng", { size: 500 }),
|
||||
]);
|
||||
@@ -153,7 +135,8 @@ export function useProcessData() {
|
||||
setInstructions(wiData);
|
||||
setItemNameMap(newItemNameMap);
|
||||
setItemTypeMap(newItemTypeMap);
|
||||
setAllProcesses((procRes.data ?? []) as WorkOrderProcess[]);
|
||||
const rawRows: WorkOrderProcessRaw[] = procRes.data?.data ?? [];
|
||||
setAllProcesses(rawRows.map(normalizeWorkOrderProcess));
|
||||
setProcessList((pmRes.data ?? []) as ProcessMng[]);
|
||||
setEquipmentList((eqRes.data ?? []) as EquipmentMng[]);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SupplierModal, type Supplier, type PartnerSourceConfig } from "../../_components/inbound/SupplierModal";
|
||||
import { EquipmentModal, type EquipmentItem } from "../../_components/common/EquipmentModal";
|
||||
@@ -30,6 +30,8 @@ const PROCESS_SOURCE: PartnerSourceConfig = {
|
||||
const COLS_OPTIONS: ColsKey[] = [1, 2, 3];
|
||||
/** 신규 POP 전용 카드 열 localStorage 키 (구 POP `workorder-card-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;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -69,6 +71,72 @@ export default function ProductionProcessPage() {
|
||||
const [equipments, setEquipments] = useState<EquipmentItem[]>([]);
|
||||
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(() => {
|
||||
if (!selectedProcess?.customer_code) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ProcessWork } from "../../../_components/production/ProcessWork";
|
||||
|
||||
export default function WorkPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const processId = params.processId as string;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* ===== Back + Title ===== */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/COMPANY_7/pop/production/process")}
|
||||
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">공정 작업</h1>
|
||||
</div>
|
||||
<ProcessWork processId={processId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { MenuProvider } from "@/contexts/MenuContext";
|
||||
import { MessengerProvider } from "@/contexts/MessengerContext";
|
||||
@@ -8,6 +11,13 @@ import { MessengerFAB } from "@/components/messenger/MessengerFAB";
|
||||
import { MessengerModal } from "@/components/messenger/MessengerModal";
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isPop = pathname.includes("/pop/") || pathname.endsWith("/pop");
|
||||
|
||||
if (isPop) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<MenuProvider>
|
||||
|
||||
@@ -523,6 +523,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
// POP 모드 진입 핸들러
|
||||
const handlePopModeClick = async () => {
|
||||
try {
|
||||
// PC → POP 전환 시 전체화면 적용
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen();
|
||||
}
|
||||
} catch {
|
||||
// 전체화면 미지원 또는 거부 시 무시
|
||||
}
|
||||
|
||||
const response = await menuApi.getPopMenus();
|
||||
if (response.success && response.data) {
|
||||
const { childMenus, landingMenu } = response.data;
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
* DB(cart_items 테이블) <-> 로컬 상태를 동기화하고 변경사항(dirty)을 감지한다.
|
||||
*
|
||||
* 동작 방식:
|
||||
* 1. 마운트 시 DB에서 해당 screen_id + user_id의 장바구니를 로드
|
||||
* 1. 마운트 시 DB에서 해당 카테고리(inbound/outbound)의 장바구니를 로드
|
||||
* 2. addItem/removeItem/updateItem은 로컬 상태만 변경 (DB 미반영, dirty 상태)
|
||||
* 3. saveToDb 호출 시 로컬 상태를 DB에 일괄 반영 (추가/수정/삭제)
|
||||
* 4. isDirty = 로컬 상태와 DB 마지막 로드 상태의 차이 존재 여부
|
||||
*
|
||||
* 사용 예시:
|
||||
* ```typescript
|
||||
* const cart = useCartSync("SCR-001", "item_info");
|
||||
* const cart = useCartSync("inbound");
|
||||
*
|
||||
* // 품목 추가 (로컬만, DB 미반영)
|
||||
* cart.addItem({ row, quantity: 10 }, "D1710008");
|
||||
* // 품목 추가 (로컬만, DB 미반영) — sourceTable은 항목별로 전달
|
||||
* cart.addItem({ row, quantity: 10 }, "D1710008", "purchase_detail");
|
||||
*
|
||||
* // DB 저장 (pop-icon 확인 모달에서 호출)
|
||||
* await cart.saveToDb();
|
||||
@@ -40,6 +40,8 @@ export interface CartChanges {
|
||||
toDelete: (string | number)[];
|
||||
}
|
||||
|
||||
export type CartCategory = "inbound" | "outbound";
|
||||
|
||||
export interface UseCartSyncReturn {
|
||||
cartItems: CartItemWithId[];
|
||||
savedItems: CartItemWithId[];
|
||||
@@ -48,7 +50,7 @@ export interface UseCartSyncReturn {
|
||||
isDirty: boolean;
|
||||
loading: boolean;
|
||||
|
||||
addItem: (item: CartItem, rowKey: string) => void;
|
||||
addItem: (item: CartItem, rowKey: string, sourceTable?: string) => void;
|
||||
removeItem: (rowKey: string) => void;
|
||||
updateItemQuantity: (
|
||||
rowKey: string,
|
||||
@@ -111,8 +113,9 @@ function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
|
||||
|
||||
function cartItemToDbRecord(
|
||||
item: CartItemWithId,
|
||||
screenId: string,
|
||||
cartType: string,
|
||||
selectedColumns?: string[],
|
||||
screenId?: string,
|
||||
): Record<string, unknown> {
|
||||
const rowData =
|
||||
selectedColumns && selectedColumns.length > 0
|
||||
@@ -121,9 +124,8 @@ function cartItemToDbRecord(
|
||||
)
|
||||
: item.row;
|
||||
|
||||
return {
|
||||
cart_type: "pop",
|
||||
screen_id: screenId,
|
||||
const record: Record<string, unknown> = {
|
||||
cart_type: cartType,
|
||||
source_table: item.sourceTable,
|
||||
row_key: item.rowKey,
|
||||
row_data: JSON.stringify(rowData),
|
||||
@@ -136,6 +138,11 @@ function cartItemToDbRecord(
|
||||
status: item.status,
|
||||
memo: item.memo || "",
|
||||
};
|
||||
// 레거시 모드: screen_id 포함
|
||||
if (screenId) {
|
||||
record.screen_id = screenId;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
// ===== 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(
|
||||
screenId: string,
|
||||
sourceTable: string,
|
||||
categoryOrScreenId: string,
|
||||
sourceTable?: string,
|
||||
): 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 [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const screenIdRef = useRef(screenId);
|
||||
const sourceTableRef = useRef(sourceTable);
|
||||
screenIdRef.current = screenId;
|
||||
sourceTableRef.current = sourceTable;
|
||||
const categoryRef = useRef(categoryOrScreenId);
|
||||
categoryRef.current = categoryOrScreenId;
|
||||
const cartTypeRef = useRef(cartTypeValue);
|
||||
cartTypeRef.current = cartTypeValue;
|
||||
const legacySourceTableRef = useRef(legacySourceTable);
|
||||
legacySourceTableRef.current = legacySourceTable;
|
||||
|
||||
// ----- DB에서 장바구니 로드 -----
|
||||
const loadFromDb = useCallback(async () => {
|
||||
if (!screenId || !sourceTable) return;
|
||||
if (!categoryOrScreenId) return;
|
||||
setLoading(true);
|
||||
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", {
|
||||
size: 500,
|
||||
filters: {
|
||||
screen_id: screenId,
|
||||
cart_type: "pop",
|
||||
status: "in_cart",
|
||||
},
|
||||
filters,
|
||||
});
|
||||
|
||||
const items = (result.data || []).map(dbRowToCartItem);
|
||||
@@ -194,7 +217,7 @@ export function useCartSync(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [screenId, sourceTable]);
|
||||
}, [categoryOrScreenId, cartTypeValue, screenIdValue]);
|
||||
|
||||
// 마운트 시 자동 로드
|
||||
useEffect(() => {
|
||||
@@ -213,7 +236,7 @@ export function useCartSync(
|
||||
|
||||
// ----- 로컬 조작 (DB 미반영) -----
|
||||
|
||||
const addItem = useCallback((item: CartItem, rowKey: string) => {
|
||||
const addItem = useCallback((item: CartItem, rowKey: string, sourceTable?: string) => {
|
||||
setCartItems((prev) => {
|
||||
const exists = prev.find((i) => i.rowKey === rowKey);
|
||||
if (exists) {
|
||||
@@ -232,7 +255,7 @@ export function useCartSync(
|
||||
const newItem: CartItemWithId = {
|
||||
...item,
|
||||
cartId: undefined,
|
||||
sourceTable: sourceTableRef.current,
|
||||
sourceTable: sourceTable || legacySourceTableRef.current || "",
|
||||
rowKey,
|
||||
status: "in_cart",
|
||||
_origin: "local",
|
||||
@@ -293,7 +316,8 @@ export function useCartSync(
|
||||
// ----- diff 계산 (백엔드 전송용) -----
|
||||
const getChanges = useCallback(
|
||||
(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 toDeleteItems = savedItems.filter(
|
||||
@@ -306,7 +330,6 @@ export function useCartSync(
|
||||
if (!c.cartId) return false;
|
||||
const saved = savedMap.get(c.rowKey);
|
||||
if (!saved) return false;
|
||||
// row JSON 비교 (검사 결과 등 포함)
|
||||
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
|
||||
return (
|
||||
c.quantity !== saved.quantity ||
|
||||
@@ -318,11 +341,11 @@ export function useCartSync(
|
||||
|
||||
return {
|
||||
toCreate: toCreateItems.map((item) =>
|
||||
cartItemToDbRecord(item, currentScreenId, selectedColumns),
|
||||
cartItemToDbRecord(item, currentCartType, selectedColumns, currentScreenId),
|
||||
),
|
||||
toUpdate: toUpdateItems.map((item) => ({
|
||||
id: item.cartId,
|
||||
...cartItemToDbRecord(item, currentScreenId, selectedColumns),
|
||||
...cartItemToDbRecord(item, currentCartType, selectedColumns, currentScreenId),
|
||||
})),
|
||||
toDelete: toDeleteItems.map((item) => item.cartId!),
|
||||
};
|
||||
@@ -335,7 +358,8 @@ export function useCartSync(
|
||||
async (selectedColumns?: string[]): Promise<boolean> => {
|
||||
setSyncStatus("saving");
|
||||
try {
|
||||
const currentScreenId = screenIdRef.current;
|
||||
const currentCartType = cartTypeRef.current;
|
||||
const currentScreenId = isLegacy ? categoryRef.current : undefined;
|
||||
|
||||
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
|
||||
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||
@@ -375,8 +399,9 @@ export function useCartSync(
|
||||
for (const item of toCreate) {
|
||||
const record = cartItemToDbRecord(
|
||||
item,
|
||||
currentScreenId,
|
||||
currentCartType,
|
||||
selectedColumns,
|
||||
currentScreenId,
|
||||
);
|
||||
// cart_items.id는 NOT NULL + 자동생성 없음 → UUID 직접 생성
|
||||
const recordWithId = { id: crypto.randomUUID(), ...record };
|
||||
@@ -386,8 +411,9 @@ export function useCartSync(
|
||||
for (const item of toUpdate) {
|
||||
const record = cartItemToDbRecord(
|
||||
item,
|
||||
currentScreenId,
|
||||
currentCartType,
|
||||
selectedColumns,
|
||||
currentScreenId,
|
||||
);
|
||||
promises.push(
|
||||
dataApi.updateRecord("cart_items", item.cartId!, record),
|
||||
|
||||
@@ -132,6 +132,14 @@ export const useLogin = () => {
|
||||
if (isPopMode) {
|
||||
const popPath = result.data?.popLandingPath;
|
||||
if (popPath) {
|
||||
// POP 모드 로그인 시 전체화면 전환 시도
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen();
|
||||
}
|
||||
} catch {
|
||||
// 전체화면 미지원 또는 거부 시 무시
|
||||
}
|
||||
router.push(popPath);
|
||||
} else {
|
||||
setError("POP 화면이 설정되어 있지 않습니다. 관리자에게 메뉴 관리에서 POP 화면을 설정해달라고 요청하세요.");
|
||||
|
||||
@@ -97,6 +97,28 @@ export async function deletePkgUnit(id: 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 ---
|
||||
|
||||
export async function getPkgUnitItems(pkgCode: string) {
|
||||
@@ -114,6 +136,29 @@ export async function deletePkgUnitItem(id: 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 ---
|
||||
|
||||
export async function getLoadingUnits() {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"components/screen/ScreenDesigner_old.tsx",
|
||||
"components/admin/dashboard/widgets/yard-3d/Yard3DCanvas_NEW.tsx",
|
||||
"components/flow/FlowDataListModal.tsx",
|
||||
"test-scenarios"
|
||||
"test-scenarios/**",
|
||||
"app/test-type-safety/**"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user