diff --git a/backend-node/src/controllers/popInOutHistoryController.ts b/backend-node/src/controllers/popInOutHistoryController.ts index 8d751c29..4dd09759 100644 --- a/backend-node/src/controllers/popInOutHistoryController.ts +++ b/backend-node/src/controllers/popInOutHistoryController.ts @@ -125,12 +125,9 @@ export async function getInOutHistory( LEFT JOIN warehouse_info wh ON wh.warehouse_code = ih.warehouse_code AND wh.company_code = ih.company_code + -- 이동 row 는 출발(-)/도착(+) 양쪽 모두 노출 (2026-05-11 사용자 결정) WHERE ($1::text = '*' OR ih.company_code = $1) AND ih.transaction_type = ANY($2::text[]) - AND NOT ( - ih.transaction_type = '이동' - AND COALESCE(CAST(NULLIF(ih.quantity::text, '') AS numeric), 0) < 0 - ) ) `; diff --git a/backend-node/src/controllers/popInventoryMoveController.ts b/backend-node/src/controllers/popInventoryMoveController.ts new file mode 100644 index 00000000..b017f39c --- /dev/null +++ b/backend-node/src/controllers/popInventoryMoveController.ts @@ -0,0 +1,386 @@ +/** + * popInventoryMoveController — 신 POP 재고이동 전용 컨트롤러 + * + * 사용처: frontend/app/(main)/COMPANY_X/pop/inventory/move/ + * + * 동작: + * - 출발 stock 차감 + 도착 stock UPSERT + * - inventory_history 2건 INSERT (출발 -qty, 도착 +qty, transaction_type='이동') + * - 동일창고 이동 가드: warehouse_location 등록 시에만 허용 + * - 음수 재고 차단 + * - cart_type='inventory-move' 임시저장 + */ + +import { Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const CART_TYPE = "inventory-move"; + +interface CommitItem { + stock_id: string; + qty: number; +} + +interface CommitBody { + items: CommitItem[]; + to_warehouse_code: string; + to_location_code?: string; +} + +/** + * GET /api/pop/inventory/move/stock-list + * 재고이동 화면용 재고 목록 조회 (출발창고 + 키워드 필터) + * + * Query: warehouse_code (선택, 'all' or 창고코드), keyword (선택, 품목명/코드) + */ +export const getMoveStockList = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { warehouse_code, keyword } = req.query; + const conditions = ["ist.company_code = $1"]; + const params: any[] = [companyCode]; + let idx = 2; + + conditions.push("COALESCE(CAST(NULLIF(ist.current_qty, '') AS numeric), 0) > 0"); + + if (warehouse_code && warehouse_code !== "all") { + conditions.push(`ist.warehouse_code = $${idx++}`); + params.push(warehouse_code); + } + + if (keyword) { + conditions.push(`( + ist.item_code ILIKE $${idx} + OR COALESCE(ii.item_name, '') ILIKE $${idx} + OR COALESCE(ii.item_number, '') ILIKE $${idx} + )`); + params.push(`%${keyword}%`); + idx++; + } + + const result = await pool.query( + `SELECT + ist.id, + ist.item_code, + COALESCE(ii.item_name, ist.item_code) AS item_name, + COALESCE(ii.item_number, ist.item_code) AS item_number, + COALESCE(ii.size, '') AS spec, + COALESCE(NULLIF(ii.inventory_unit, ''), NULLIF(ii.unit, ''), 'EA') AS unit, + ist.warehouse_code, + COALESCE(wi.warehouse_name, ist.warehouse_code) AS warehouse_name, + COALESCE(ist.location_code, '') AS location_code, + ist.current_qty + FROM inventory_stock ist + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, size, unit, inventory_unit, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code + LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code + AND ist.company_code = wi.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY COALESCE(ii.item_name, ist.item_code) + LIMIT 500`, + params + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory/move] stock-list 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * POST /api/pop/inventory/move/commit + * 재고이동 일괄 확정 + * + * Body: { items: CommitItem[], to_warehouse_code, to_location_code? } + * + * 가드: + * - qty > 0 + * - qty <= 출발.current_qty (음수 차단) + * - 출발창고 == 도착창고 + 동일 location → 차단 + * - 출발창고 == 도착창고 + 다른 location → warehouse_location 등록 시만 허용 + * - 출발창고 != 도착창고 → 허용 + * + * 처리: + * - 출발 stock current_qty -= qty + * - 도착 stock UPSERT (있으면 += qty, 없으면 INSERT) + * - inventory_history 2건 INSERT (transaction_type='이동') + * - cart_items cart_type='inventory-move' saved → cancelled + */ +export const commitMove = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const userName = req.user?.userName || userId; + + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { items, to_warehouse_code, to_location_code } = req.body as CommitBody; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "이동 항목이 없습니다" }); + } + if (!to_warehouse_code) { + return res.status(400).json({ success: false, message: "도착 창고를 선택하세요" }); + } + + const toLocation = to_location_code || ""; + + await client.query("BEGIN"); + + let moveCount = 0; + const results: Array<{ stock_id: string; status: string; reason?: string }> = []; + + for (const item of items) { + // 1. 출발 stock 검증 + company_code 필터 + const stockCheck = await client.query( + `SELECT id, item_code, warehouse_code, location_code, + COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS current_qty + FROM inventory_stock + WHERE id = $1 AND company_code = $2`, + [item.stock_id, companyCode] + ); + + if (stockCheck.rowCount === 0) { + results.push({ stock_id: item.stock_id, status: "not_found" }); + continue; + } + + const stock = stockCheck.rows[0]; + const currentQty = parseFloat(stock.current_qty) || 0; + const qty = item.qty; + + // 2. qty 검증 + if (!qty || qty <= 0) { + results.push({ stock_id: item.stock_id, status: "invalid_qty" }); + continue; + } + if (qty > currentQty) { + results.push({ stock_id: item.stock_id, status: "exceeds_stock", reason: `재고 ${currentQty} 초과` }); + continue; + } + + // 3. 동일창고 가드 + const fromLocation = stock.location_code || ""; + const sameWarehouse = stock.warehouse_code === to_warehouse_code; + const sameLocation = fromLocation === toLocation; + + if (sameWarehouse && sameLocation) { + results.push({ stock_id: item.stock_id, status: "same_position", reason: "출발과 도착이 동일" }); + continue; + } + + if (sameWarehouse && !sameLocation) { + // 같은 창고 다른 location → warehouse_location 등록 필수 + const locCheck = await client.query( + `SELECT 1 FROM warehouse_location + WHERE company_code = $1 AND warehouse_code = $2 + LIMIT 1`, + [companyCode, to_warehouse_code] + ); + if (locCheck.rowCount === 0) { + results.push({ + stock_id: item.stock_id, + status: "no_rack", + reason: "동일창고 이동은 렉구조 등록 필요" + }); + continue; + } + } + + // 4. 출발 stock 차감 + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) - $1, 0) AS text), + updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(qty), userId, item.stock_id, companyCode] + ); + + // 5. 도착 stock UPSERT + const existingDest = await client.query( + `SELECT id, COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) AS current_qty + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code,'') = COALESCE($3,'') + AND COALESCE(location_code,'') = COALESCE($4,'') + LIMIT 1`, + [companyCode, stock.item_code, to_warehouse_code, toLocation] + ); + + let destBalance: number; + if (existingDest.rows.length > 0) { + const destPrev = parseFloat(existingDest.rows[0].current_qty) || 0; + destBalance = destPrev + qty; + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty,'') AS numeric),0) + $1 AS text), + updated_date = NOW(), writer = $2 + WHERE id = $3 AND company_code = $4`, + [String(qty), userId, existingDest.rows[0].id, companyCode] + ); + } else { + destBalance = qty; + await client.query( + `INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, created_date, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), $6)`, + [companyCode, stock.item_code, to_warehouse_code, toLocation, String(qty), userId] + ); + } + + const fromBalance = Math.max(currentQty - qty, 0); + + // 6. inventory_history 2건 INSERT + // 출발 (-qty) + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, reason, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, + '이동', NOW(), $5, $6, NULL, NULL, $7, $8, NOW())`, + [companyCode, stock.item_code, stock.warehouse_code, fromLocation, + String(-qty), String(fromBalance), userId, userName] + ); + // 도착 (+qty) + await client.query( + `INSERT INTO inventory_history (id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, reason, remark, writer, manager_name, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, + '이동', NOW(), $5, $6, NULL, NULL, $7, $8, NOW())`, + [companyCode, stock.item_code, to_warehouse_code, toLocation, + String(qty), String(destBalance), userId, userName] + ); + + moveCount++; + results.push({ stock_id: item.stock_id, status: "ok" }); + } + + // 7. 임시저장 cart_items 자동 cancelled + await client.query( + `UPDATE cart_items SET status = 'cancelled', updated_date = NOW() + WHERE company_code = $1 AND cart_type = $2 AND status = 'saved'`, + [companyCode, CART_TYPE] + ); + + await client.query("COMMIT"); + + logger.info("[pop/inventory/move] commit 완료", { + companyCode, userId, moveCount, total: items.length + }); + + return res.json({ + success: true, + message: `이동 ${moveCount}건 처리 완료`, + data: { moveCount, results } + }); + + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/inventory/move] commit 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; + +/** + * GET /api/pop/inventory/move/temp-load + * 임시저장 불러오기 (cart_type='inventory-move' + status='saved') + */ +export const loadTempMove = async (req: any, res: Response) => { + const pool = getPool(); + + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const result = await pool.query( + `SELECT id, row_data, row_key, created_date + FROM cart_items + WHERE company_code = $1 AND cart_type = $2 AND status = 'saved' + ORDER BY created_date`, + [companyCode, CART_TYPE] + ); + + return res.json({ success: true, data: result.rows }); + + } catch (error: any) { + logger.error("[pop/inventory/move] temp-load 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * POST /api/pop/inventory/move/temp-save + * 임시저장 일괄 덮어쓰기 + * + * Body: { items: any[] } + */ +export const saveTempMove = async (req: any, res: Response) => { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 정보 없음" }); + } + + const { items } = req.body as { items: any[] }; + if (!Array.isArray(items)) { + return res.status(400).json({ success: false, message: "items 배열이 필요합니다" }); + } + + await client.query("BEGIN"); + + await client.query( + `UPDATE cart_items SET status = 'cancelled', updated_date = NOW() + WHERE company_code = $1 AND cart_type = $2 AND status = 'saved'`, + [companyCode, CART_TYPE] + ); + + void userId; + for (const item of items) { + await client.query( + `INSERT INTO cart_items (id, company_code, cart_type, row_key, row_data, status, created_date) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, 'saved', NOW())`, + [ + companyCode, + CART_TYPE, + item?.row_key || item?.stock_id || item?.stockId || "", + JSON.stringify(item) + ] + ); + } + + await client.query("COMMIT"); + + return res.json({ success: true, message: `${items.length}건 임시저장 완료` }); + + } catch (error: any) { + await client.query("ROLLBACK").catch(() => {}); + logger.error("[pop/inventory/move] temp-save 오류:", error); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +}; diff --git a/backend-node/src/routes/popInventoryRoutes.ts b/backend-node/src/routes/popInventoryRoutes.ts index 9ffdc2b5..676a4d5c 100644 --- a/backend-node/src/routes/popInventoryRoutes.ts +++ b/backend-node/src/routes/popInventoryRoutes.ts @@ -6,6 +6,12 @@ import { loadTempAdjust, saveTempAdjust, } from "../controllers/popInventoryAdjustController"; +import { + getMoveStockList, + commitMove, + loadTempMove, + saveTempMove, +} from "../controllers/popInventoryMoveController"; import { getInOutHistory } from "../controllers/popInOutHistoryController"; import { getInboundDetail, @@ -31,6 +37,14 @@ router.get("/adjust/temp-load", loadTempAdjust); // 임시저장 일괄 덮어쓰기 (자동 임시저장) router.post("/adjust/temp-save", saveTempAdjust); +// ============================================================ +// 신 POP 재고이동 (v2) — popInventoryMoveController +// ============================================================ +router.get("/move/stock-list", getMoveStockList); +router.post("/move/commit", commitMove); +router.get("/move/temp-load", loadTempMove); +router.post("/move/temp-save", saveTempMove); + // 입출고관리 통합 이력 조회 (3-way UNION: inbound + outbound + inventory_history) router.get("/inout-history", getInOutHistory); diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/common/PopShell.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopShell.tsx index dd81a1ee..f4199f70 100644 --- a/frontend/app/(main)/COMPANY_10/pop/_components/common/PopShell.tsx +++ b/frontend/app/(main)/COMPANY_10/pop/_components/common/PopShell.tsx @@ -105,7 +105,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false, if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch {} } - router.push("/"); + router.push("/main"); }; const handlePopHome = () => { diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/production/AcceptProcessModal.tsx index 3980a595..14a2f6af 100644 --- a/frontend/app/(main)/COMPANY_10/pop/_components/production/AcceptProcessModal.tsx +++ b/frontend/app/(main)/COMPANY_10/pop/_components/production/AcceptProcessModal.tsx @@ -52,9 +52,9 @@ export function AcceptProcessModal({ useEffect(() => { if (open) { - setQty("0"); + setQty(String(maxQty)); } - }, [open]); + }, [open, maxQty]); const qtyNum = parseInt(qty, 10) || 0; const isOverMax = qtyNum > maxQty; diff --git a/frontend/app/(main)/COMPANY_10/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_10/pop/_components/production/ProcessWork.tsx index 08cb0a7e..2464bf49 100644 --- a/frontend/app/(main)/COMPANY_10/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_10/pop/_components/production/ProcessWork.tsx @@ -884,6 +884,28 @@ export function ProcessWork({ [groups, selectedGroupId], ); + // Phase 종료(타이머 complete) 전이 시 다음 phase 첫 그룹으로 자동 이동 + const prevPhaseStatusRef = useRef | null>(null); + useEffect(() => { + const next: Record = {}; + for (const phase of availablePhases) { + next[phase] = phaseTimerMap[phase]?.status ?? "idle"; + } + const prev = prevPhaseStatusRef.current; + prevPhaseStatusRef.current = next; + if (prev === null) return; + if (!selectedGroup) return; + const cur = selectedGroup.phase; + if (next[cur] !== "completed") return; + if (!["idle", "running", "paused"].includes(prev[cur] ?? "")) return; + const idx = availablePhases.indexOf(cur); + const nextPhase = availablePhases[idx + 1]; + if (!nextPhase) return; + const grps = groupsByPhase[nextPhase]; + if (!grps || grps.length === 0) return; + setSelectedGroupId(grps[0].itemId); + }, [phaseTimerMap, availablePhases, selectedGroup, groupsByPhase]); + const currentItems = useMemo( () => selectedGroup?.items.sort( @@ -1795,13 +1817,15 @@ export function ProcessWork({ 정지 +
+ + + +
+

재고이동

+

출발 창고에서 도착 창고로 품목을 이동합니다

+
+
+ + + + + + {/* ===== Body Grid ===== */} +
+ {/* ===== Left: Tabs + Filter + Cards ===== */} +
+ {/* Source Tabs (창고 / 공정) */} +
+
+ setSourceTab("warehouse")} /> + setSourceTab("process")} disabled /> +
+
+ + {/* Filter */} +
+ {/* Warehouse Chips — 좌우 스크롤 */} +
+ {fromChips.map((c) => { + const active = selectedFromCode === c.code; + return ( + + ); + })} +
+ + {/* Search Bar */} +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") setAppliedKeyword(keyword.trim()); }} + placeholder="품목명 / 코드 검색" + className="flex-1 px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-orange-400 bg-white" + /> + +
+
+ + {/* Stock Cards Grid */} +
+ {sourceTab === "process" ? ( +
+ 공정 출발은 다음 세션에서 작업 예정입니다 +
+ ) : stocksLoading ? ( +
+ 불러오는 중... +
+ ) : stocks.length === 0 ? ( +
+ 재고가 없습니다 +
+ ) : ( + stocks.map((s) => { + const queued = queueIds.has(s.id); + const queuedRow = queue.find((q) => q.stock_id === s.id); + return ( + addToQueue(s, parseFloat(s.current_qty) || 0)} + onQtyClick={() => openNumpad(s)} + /> + ); + }) + )} +
+
+ + {/* ===== Right: Move Queue Panel ===== */} +
+
+
+

이동 대기

+ +
+ + {queueCount}건 + +
+ +
+ {queue.length === 0 ? ( +
+ 선택된 항목이 없습니다 +
+ ) : ( + queue.map((r) => ( + removeFromQueue(r.stock_id)} + /> + )) + )} +
+ + +
+
+ + {/* ===== Destination Warehouse Modal ===== */} + setDestModalOpen(false)} + onSelect={handleDestSelect} + /> + + {/* ===== Numpad Modal ===== */} + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget ? parseFloat(numpadTarget.current_qty) || 0 : 0} + itemName={numpadTarget?.item_name ?? ""} + itemNumber={numpadTarget?.item_number ?? ""} + initialQty={ + numpadTarget + ? (queue.find((q) => q.stock_id === numpadTarget.id)?.qty ?? + parseFloat(numpadTarget.current_qty) ?? 0) + : 0 + } + /> + + {/* ===== Warning Dialog (출발/도착 변경) ===== */} + {pendingChange && ( + setPendingChange(null)} + onConfirm={confirmPendingChange} + /> + )} + + ); +} + +// ===== Source Tab Button ===== + +function SourceTabButton({ + label, + active, + onClick, + disabled, +}: { + label: string; + active: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ===== Stock Item Card ===== + +function StockItemCard({ + stock, + queued, + queuedQty, + onAdd, + onQtyClick, +}: { + stock: MoveStockItem; + queued: boolean; + queuedQty: number | undefined; + onAdd: () => void; + onQtyClick: () => void; +}) { + const stockQty = parseFloat(stock.current_qty) || 0; + const displayQty = queued && queuedQty !== undefined ? queuedQty : stockQty; + return ( +
+
+
+
+ {stock.item_name} / {stock.item_number} +
+
+ {stock.spec && {stock.spec}} + {stock.warehouse_name} + {stock.location_code && · {stock.location_code}} +
+
+ +
+ +
+ ); +} + +// ===== Destination Warehouse Modal ===== + +function DestWarehouseModal({ + open, + warehouses, + selectedCode, + onClose, + onSelect, +}: { + open: boolean; + warehouses: WarehouseOption[]; + selectedCode: string | null; + onClose: () => void; + onSelect: (w: WarehouseOption) => void; +}) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

도착창고 선택

+ +
+
+ {warehouses.length === 0 ? ( +
+ 등록된 창고가 없습니다 +
+ ) : ( + warehouses.map((w) => { + const active = selectedCode === w.warehouse_code; + return ( + + ); + }) + )} +
+
+
+ ); +} + +// ===== Queue Card ===== + +function QueueCard({ + row, + destName, + onRemove, +}: { + row: QueueRow; + destName: string | null; + onRemove: () => void; +}) { + return ( +
+
+
+ {row.item_code} {row.item_name} +
+
+
+ {row.from_warehouse_name} + + + + + {destName || "도착 미선택"} + +
+
+ + {row.qty.toLocaleString()} + + EA +
+
+
+ +
+ ); +} + +// ===== Confirm Dialog ===== + +function ConfirmDialog({ + message, + onCancel, + onConfirm, +}: { + message: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
e.stopPropagation()} + > +

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_10/pop/inventory/page.tsx b/frontend/app/(main)/COMPANY_10/pop/inventory/page.tsx index 3fbe4f18..98d2c83f 100644 --- a/frontend/app/(main)/COMPANY_10/pop/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_10/pop/inventory/page.tsx @@ -61,6 +61,10 @@ function InventoryHome() { router.push(companyPath("/pop/inventory/adjust")); return; } + if (item.id === "move") { + router.push(companyPath("/pop/inventory/move")); + return; + } }; return ( diff --git a/frontend/app/(main)/COMPANY_16/pop/_components/common/PopShell.tsx b/frontend/app/(main)/COMPANY_16/pop/_components/common/PopShell.tsx index 46b3e9e4..cb7a5cb1 100644 --- a/frontend/app/(main)/COMPANY_16/pop/_components/common/PopShell.tsx +++ b/frontend/app/(main)/COMPANY_16/pop/_components/common/PopShell.tsx @@ -105,7 +105,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false, if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch {} } - router.push("/"); + router.push("/main"); }; const handlePopHome = () => { diff --git a/frontend/app/(main)/COMPANY_16/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_16/pop/_components/production/AcceptProcessModal.tsx index 3980a595..14a2f6af 100644 --- a/frontend/app/(main)/COMPANY_16/pop/_components/production/AcceptProcessModal.tsx +++ b/frontend/app/(main)/COMPANY_16/pop/_components/production/AcceptProcessModal.tsx @@ -52,9 +52,9 @@ export function AcceptProcessModal({ useEffect(() => { if (open) { - setQty("0"); + setQty(String(maxQty)); } - }, [open]); + }, [open, maxQty]); const qtyNum = parseInt(qty, 10) || 0; const isOverMax = qtyNum > maxQty; diff --git a/frontend/app/(main)/COMPANY_16/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_16/pop/_components/production/ProcessWork.tsx index 08cb0a7e..2464bf49 100644 --- a/frontend/app/(main)/COMPANY_16/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_16/pop/_components/production/ProcessWork.tsx @@ -884,6 +884,28 @@ export function ProcessWork({ [groups, selectedGroupId], ); + // Phase 종료(타이머 complete) 전이 시 다음 phase 첫 그룹으로 자동 이동 + const prevPhaseStatusRef = useRef | null>(null); + useEffect(() => { + const next: Record = {}; + for (const phase of availablePhases) { + next[phase] = phaseTimerMap[phase]?.status ?? "idle"; + } + const prev = prevPhaseStatusRef.current; + prevPhaseStatusRef.current = next; + if (prev === null) return; + if (!selectedGroup) return; + const cur = selectedGroup.phase; + if (next[cur] !== "completed") return; + if (!["idle", "running", "paused"].includes(prev[cur] ?? "")) return; + const idx = availablePhases.indexOf(cur); + const nextPhase = availablePhases[idx + 1]; + if (!nextPhase) return; + const grps = groupsByPhase[nextPhase]; + if (!grps || grps.length === 0) return; + setSelectedGroupId(grps[0].itemId); + }, [phaseTimerMap, availablePhases, selectedGroup, groupsByPhase]); + const currentItems = useMemo( () => selectedGroup?.items.sort( @@ -1795,13 +1817,15 @@ export function ProcessWork({ 정지 +
+ + + +
+

재고이동

+

출발 창고에서 도착 창고로 품목을 이동합니다

+
+
+ + + + + + {/* ===== Body Grid ===== */} +
+ {/* ===== Left: Tabs + Filter + Cards ===== */} +
+ {/* Source Tabs (창고 / 공정) */} +
+
+ setSourceTab("warehouse")} /> + setSourceTab("process")} disabled /> +
+
+ + {/* Filter */} +
+ {/* Warehouse Chips — 좌우 스크롤 */} +
+ {fromChips.map((c) => { + const active = selectedFromCode === c.code; + return ( + + ); + })} +
+ + {/* Search Bar */} +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") setAppliedKeyword(keyword.trim()); }} + placeholder="품목명 / 코드 검색" + className="flex-1 px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-orange-400 bg-white" + /> + +
+
+ + {/* Stock Cards Grid */} +
+ {sourceTab === "process" ? ( +
+ 공정 출발은 다음 세션에서 작업 예정입니다 +
+ ) : stocksLoading ? ( +
+ 불러오는 중... +
+ ) : stocks.length === 0 ? ( +
+ 재고가 없습니다 +
+ ) : ( + stocks.map((s) => { + const queued = queueIds.has(s.id); + const queuedRow = queue.find((q) => q.stock_id === s.id); + return ( + addToQueue(s, parseFloat(s.current_qty) || 0)} + onQtyClick={() => openNumpad(s)} + /> + ); + }) + )} +
+
+ + {/* ===== Right: Move Queue Panel ===== */} +
+
+
+

이동 대기

+ +
+ + {queueCount}건 + +
+ +
+ {queue.length === 0 ? ( +
+ 선택된 항목이 없습니다 +
+ ) : ( + queue.map((r) => ( + removeFromQueue(r.stock_id)} + /> + )) + )} +
+ + +
+
+ + {/* ===== Destination Warehouse Modal ===== */} + setDestModalOpen(false)} + onSelect={handleDestSelect} + /> + + {/* ===== Numpad Modal ===== */} + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget ? parseFloat(numpadTarget.current_qty) || 0 : 0} + itemName={numpadTarget?.item_name ?? ""} + itemNumber={numpadTarget?.item_number ?? ""} + initialQty={ + numpadTarget + ? (queue.find((q) => q.stock_id === numpadTarget.id)?.qty ?? + parseFloat(numpadTarget.current_qty) ?? 0) + : 0 + } + /> + + {/* ===== Warning Dialog (출발/도착 변경) ===== */} + {pendingChange && ( + setPendingChange(null)} + onConfirm={confirmPendingChange} + /> + )} + + ); +} + +// ===== Source Tab Button ===== + +function SourceTabButton({ + label, + active, + onClick, + disabled, +}: { + label: string; + active: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ===== Stock Item Card ===== + +function StockItemCard({ + stock, + queued, + queuedQty, + onAdd, + onQtyClick, +}: { + stock: MoveStockItem; + queued: boolean; + queuedQty: number | undefined; + onAdd: () => void; + onQtyClick: () => void; +}) { + const stockQty = parseFloat(stock.current_qty) || 0; + const displayQty = queued && queuedQty !== undefined ? queuedQty : stockQty; + return ( +
+
+
+
+ {stock.item_name} / {stock.item_number} +
+
+ {stock.spec && {stock.spec}} + {stock.warehouse_name} + {stock.location_code && · {stock.location_code}} +
+
+ +
+ +
+ ); +} + +// ===== Destination Warehouse Modal ===== + +function DestWarehouseModal({ + open, + warehouses, + selectedCode, + onClose, + onSelect, +}: { + open: boolean; + warehouses: WarehouseOption[]; + selectedCode: string | null; + onClose: () => void; + onSelect: (w: WarehouseOption) => void; +}) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

도착창고 선택

+ +
+
+ {warehouses.length === 0 ? ( +
+ 등록된 창고가 없습니다 +
+ ) : ( + warehouses.map((w) => { + const active = selectedCode === w.warehouse_code; + return ( + + ); + }) + )} +
+
+
+ ); +} + +// ===== Queue Card ===== + +function QueueCard({ + row, + destName, + onRemove, +}: { + row: QueueRow; + destName: string | null; + onRemove: () => void; +}) { + return ( +
+
+
+ {row.item_code} {row.item_name} +
+
+
+ {row.from_warehouse_name} + + + + + {destName || "도착 미선택"} + +
+
+ + {row.qty.toLocaleString()} + + EA +
+
+
+ +
+ ); +} + +// ===== Confirm Dialog ===== + +function ConfirmDialog({ + message, + onCancel, + onConfirm, +}: { + message: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
e.stopPropagation()} + > +

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/pop/inventory/page.tsx b/frontend/app/(main)/COMPANY_16/pop/inventory/page.tsx index 3fbe4f18..98d2c83f 100644 --- a/frontend/app/(main)/COMPANY_16/pop/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_16/pop/inventory/page.tsx @@ -61,6 +61,10 @@ function InventoryHome() { router.push(companyPath("/pop/inventory/adjust")); return; } + if (item.id === "move") { + router.push(companyPath("/pop/inventory/move")); + return; + } }; return ( diff --git a/frontend/app/(main)/COMPANY_29/pop/_components/common/PopShell.tsx b/frontend/app/(main)/COMPANY_29/pop/_components/common/PopShell.tsx index f96bd8c4..12fffe7a 100644 --- a/frontend/app/(main)/COMPANY_29/pop/_components/common/PopShell.tsx +++ b/frontend/app/(main)/COMPANY_29/pop/_components/common/PopShell.tsx @@ -105,7 +105,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false, if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch {} } - router.push("/"); + router.push("/main"); }; const handlePopHome = () => { diff --git a/frontend/app/(main)/COMPANY_29/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_29/pop/_components/production/AcceptProcessModal.tsx index 3980a595..14a2f6af 100644 --- a/frontend/app/(main)/COMPANY_29/pop/_components/production/AcceptProcessModal.tsx +++ b/frontend/app/(main)/COMPANY_29/pop/_components/production/AcceptProcessModal.tsx @@ -52,9 +52,9 @@ export function AcceptProcessModal({ useEffect(() => { if (open) { - setQty("0"); + setQty(String(maxQty)); } - }, [open]); + }, [open, maxQty]); const qtyNum = parseInt(qty, 10) || 0; const isOverMax = qtyNum > maxQty; diff --git a/frontend/app/(main)/COMPANY_29/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_29/pop/_components/production/ProcessWork.tsx index 08cb0a7e..2464bf49 100644 --- a/frontend/app/(main)/COMPANY_29/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_29/pop/_components/production/ProcessWork.tsx @@ -884,6 +884,28 @@ export function ProcessWork({ [groups, selectedGroupId], ); + // Phase 종료(타이머 complete) 전이 시 다음 phase 첫 그룹으로 자동 이동 + const prevPhaseStatusRef = useRef | null>(null); + useEffect(() => { + const next: Record = {}; + for (const phase of availablePhases) { + next[phase] = phaseTimerMap[phase]?.status ?? "idle"; + } + const prev = prevPhaseStatusRef.current; + prevPhaseStatusRef.current = next; + if (prev === null) return; + if (!selectedGroup) return; + const cur = selectedGroup.phase; + if (next[cur] !== "completed") return; + if (!["idle", "running", "paused"].includes(prev[cur] ?? "")) return; + const idx = availablePhases.indexOf(cur); + const nextPhase = availablePhases[idx + 1]; + if (!nextPhase) return; + const grps = groupsByPhase[nextPhase]; + if (!grps || grps.length === 0) return; + setSelectedGroupId(grps[0].itemId); + }, [phaseTimerMap, availablePhases, selectedGroup, groupsByPhase]); + const currentItems = useMemo( () => selectedGroup?.items.sort( @@ -1795,13 +1817,15 @@ export function ProcessWork({ 정지 +
+ + + +
+

재고이동

+

출발 창고에서 도착 창고로 품목을 이동합니다

+
+
+ + + + + + {/* ===== Body Grid ===== */} +
+ {/* ===== Left: Tabs + Filter + Cards ===== */} +
+ {/* Source Tabs (창고 / 공정) */} +
+
+ setSourceTab("warehouse")} /> + setSourceTab("process")} disabled /> +
+
+ + {/* Filter */} +
+ {/* Warehouse Chips — 좌우 스크롤 */} +
+ {fromChips.map((c) => { + const active = selectedFromCode === c.code; + return ( + + ); + })} +
+ + {/* Search Bar */} +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") setAppliedKeyword(keyword.trim()); }} + placeholder="품목명 / 코드 검색" + className="flex-1 px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-orange-400 bg-white" + /> + +
+
+ + {/* Stock Cards Grid */} +
+ {sourceTab === "process" ? ( +
+ 공정 출발은 다음 세션에서 작업 예정입니다 +
+ ) : stocksLoading ? ( +
+ 불러오는 중... +
+ ) : stocks.length === 0 ? ( +
+ 재고가 없습니다 +
+ ) : ( + stocks.map((s) => { + const queued = queueIds.has(s.id); + const queuedRow = queue.find((q) => q.stock_id === s.id); + return ( + addToQueue(s, parseFloat(s.current_qty) || 0)} + onQtyClick={() => openNumpad(s)} + /> + ); + }) + )} +
+
+ + {/* ===== Right: Move Queue Panel ===== */} +
+
+
+

이동 대기

+ +
+ + {queueCount}건 + +
+ +
+ {queue.length === 0 ? ( +
+ 선택된 항목이 없습니다 +
+ ) : ( + queue.map((r) => ( + removeFromQueue(r.stock_id)} + /> + )) + )} +
+ + +
+
+ + {/* ===== Destination Warehouse Modal ===== */} + setDestModalOpen(false)} + onSelect={handleDestSelect} + /> + + {/* ===== Numpad Modal ===== */} + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget ? parseFloat(numpadTarget.current_qty) || 0 : 0} + itemName={numpadTarget?.item_name ?? ""} + itemNumber={numpadTarget?.item_number ?? ""} + initialQty={ + numpadTarget + ? (queue.find((q) => q.stock_id === numpadTarget.id)?.qty ?? + parseFloat(numpadTarget.current_qty) ?? 0) + : 0 + } + /> + + {/* ===== Warning Dialog (출발/도착 변경) ===== */} + {pendingChange && ( + setPendingChange(null)} + onConfirm={confirmPendingChange} + /> + )} + + ); +} + +// ===== Source Tab Button ===== + +function SourceTabButton({ + label, + active, + onClick, + disabled, +}: { + label: string; + active: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ===== Stock Item Card ===== + +function StockItemCard({ + stock, + queued, + queuedQty, + onAdd, + onQtyClick, +}: { + stock: MoveStockItem; + queued: boolean; + queuedQty: number | undefined; + onAdd: () => void; + onQtyClick: () => void; +}) { + const stockQty = parseFloat(stock.current_qty) || 0; + const displayQty = queued && queuedQty !== undefined ? queuedQty : stockQty; + return ( +
+
+
+
+ {stock.item_name} / {stock.item_number} +
+
+ {stock.spec && {stock.spec}} + {stock.warehouse_name} + {stock.location_code && · {stock.location_code}} +
+
+ +
+ +
+ ); +} + +// ===== Destination Warehouse Modal ===== + +function DestWarehouseModal({ + open, + warehouses, + selectedCode, + onClose, + onSelect, +}: { + open: boolean; + warehouses: WarehouseOption[]; + selectedCode: string | null; + onClose: () => void; + onSelect: (w: WarehouseOption) => void; +}) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

도착창고 선택

+ +
+
+ {warehouses.length === 0 ? ( +
+ 등록된 창고가 없습니다 +
+ ) : ( + warehouses.map((w) => { + const active = selectedCode === w.warehouse_code; + return ( + + ); + }) + )} +
+
+
+ ); +} + +// ===== Queue Card ===== + +function QueueCard({ + row, + destName, + onRemove, +}: { + row: QueueRow; + destName: string | null; + onRemove: () => void; +}) { + return ( +
+
+
+ {row.item_code} {row.item_name} +
+
+
+ {row.from_warehouse_name} + + + + + {destName || "도착 미선택"} + +
+
+ + {row.qty.toLocaleString()} + + EA +
+
+
+ +
+ ); +} + +// ===== Confirm Dialog ===== + +function ConfirmDialog({ + message, + onCancel, + onConfirm, +}: { + message: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
e.stopPropagation()} + > +

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_29/pop/inventory/page.tsx b/frontend/app/(main)/COMPANY_29/pop/inventory/page.tsx index 3fbe4f18..98d2c83f 100644 --- a/frontend/app/(main)/COMPANY_29/pop/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_29/pop/inventory/page.tsx @@ -61,6 +61,10 @@ function InventoryHome() { router.push(companyPath("/pop/inventory/adjust")); return; } + if (item.id === "move") { + router.push(companyPath("/pop/inventory/move")); + return; + } }; return ( diff --git a/frontend/app/(main)/COMPANY_30/pop/_components/common/PopShell.tsx b/frontend/app/(main)/COMPANY_30/pop/_components/common/PopShell.tsx index 5252dcd9..6fb89567 100644 --- a/frontend/app/(main)/COMPANY_30/pop/_components/common/PopShell.tsx +++ b/frontend/app/(main)/COMPANY_30/pop/_components/common/PopShell.tsx @@ -105,7 +105,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false, if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch {} } - router.push("/"); + router.push("/main"); }; const handlePopHome = () => { diff --git a/frontend/app/(main)/COMPANY_30/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_30/pop/_components/production/AcceptProcessModal.tsx index 3980a595..14a2f6af 100644 --- a/frontend/app/(main)/COMPANY_30/pop/_components/production/AcceptProcessModal.tsx +++ b/frontend/app/(main)/COMPANY_30/pop/_components/production/AcceptProcessModal.tsx @@ -52,9 +52,9 @@ export function AcceptProcessModal({ useEffect(() => { if (open) { - setQty("0"); + setQty(String(maxQty)); } - }, [open]); + }, [open, maxQty]); const qtyNum = parseInt(qty, 10) || 0; const isOverMax = qtyNum > maxQty; diff --git a/frontend/app/(main)/COMPANY_30/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_30/pop/_components/production/ProcessWork.tsx index 08cb0a7e..2464bf49 100644 --- a/frontend/app/(main)/COMPANY_30/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_30/pop/_components/production/ProcessWork.tsx @@ -884,6 +884,28 @@ export function ProcessWork({ [groups, selectedGroupId], ); + // Phase 종료(타이머 complete) 전이 시 다음 phase 첫 그룹으로 자동 이동 + const prevPhaseStatusRef = useRef | null>(null); + useEffect(() => { + const next: Record = {}; + for (const phase of availablePhases) { + next[phase] = phaseTimerMap[phase]?.status ?? "idle"; + } + const prev = prevPhaseStatusRef.current; + prevPhaseStatusRef.current = next; + if (prev === null) return; + if (!selectedGroup) return; + const cur = selectedGroup.phase; + if (next[cur] !== "completed") return; + if (!["idle", "running", "paused"].includes(prev[cur] ?? "")) return; + const idx = availablePhases.indexOf(cur); + const nextPhase = availablePhases[idx + 1]; + if (!nextPhase) return; + const grps = groupsByPhase[nextPhase]; + if (!grps || grps.length === 0) return; + setSelectedGroupId(grps[0].itemId); + }, [phaseTimerMap, availablePhases, selectedGroup, groupsByPhase]); + const currentItems = useMemo( () => selectedGroup?.items.sort( @@ -1795,13 +1817,15 @@ export function ProcessWork({ 정지 +
+ + + +
+

재고이동

+

출발 창고에서 도착 창고로 품목을 이동합니다

+
+
+ + + + + + {/* ===== Body Grid ===== */} +
+ {/* ===== Left: Tabs + Filter + Cards ===== */} +
+ {/* Source Tabs (창고 / 공정) */} +
+
+ setSourceTab("warehouse")} /> + setSourceTab("process")} disabled /> +
+
+ + {/* Filter */} +
+ {/* Warehouse Chips — 좌우 스크롤 */} +
+ {fromChips.map((c) => { + const active = selectedFromCode === c.code; + return ( + + ); + })} +
+ + {/* Search Bar */} +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") setAppliedKeyword(keyword.trim()); }} + placeholder="품목명 / 코드 검색" + className="flex-1 px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-orange-400 bg-white" + /> + +
+
+ + {/* Stock Cards Grid */} +
+ {sourceTab === "process" ? ( +
+ 공정 출발은 다음 세션에서 작업 예정입니다 +
+ ) : stocksLoading ? ( +
+ 불러오는 중... +
+ ) : stocks.length === 0 ? ( +
+ 재고가 없습니다 +
+ ) : ( + stocks.map((s) => { + const queued = queueIds.has(s.id); + const queuedRow = queue.find((q) => q.stock_id === s.id); + return ( + addToQueue(s, parseFloat(s.current_qty) || 0)} + onQtyClick={() => openNumpad(s)} + /> + ); + }) + )} +
+
+ + {/* ===== Right: Move Queue Panel ===== */} +
+
+
+

이동 대기

+ +
+ + {queueCount}건 + +
+ +
+ {queue.length === 0 ? ( +
+ 선택된 항목이 없습니다 +
+ ) : ( + queue.map((r) => ( + removeFromQueue(r.stock_id)} + /> + )) + )} +
+ + +
+
+ + {/* ===== Destination Warehouse Modal ===== */} + setDestModalOpen(false)} + onSelect={handleDestSelect} + /> + + {/* ===== Numpad Modal ===== */} + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget ? parseFloat(numpadTarget.current_qty) || 0 : 0} + itemName={numpadTarget?.item_name ?? ""} + itemNumber={numpadTarget?.item_number ?? ""} + initialQty={ + numpadTarget + ? (queue.find((q) => q.stock_id === numpadTarget.id)?.qty ?? + parseFloat(numpadTarget.current_qty) ?? 0) + : 0 + } + /> + + {/* ===== Warning Dialog (출발/도착 변경) ===== */} + {pendingChange && ( + setPendingChange(null)} + onConfirm={confirmPendingChange} + /> + )} + + ); +} + +// ===== Source Tab Button ===== + +function SourceTabButton({ + label, + active, + onClick, + disabled, +}: { + label: string; + active: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ===== Stock Item Card ===== + +function StockItemCard({ + stock, + queued, + queuedQty, + onAdd, + onQtyClick, +}: { + stock: MoveStockItem; + queued: boolean; + queuedQty: number | undefined; + onAdd: () => void; + onQtyClick: () => void; +}) { + const stockQty = parseFloat(stock.current_qty) || 0; + const displayQty = queued && queuedQty !== undefined ? queuedQty : stockQty; + return ( +
+
+
+
+ {stock.item_name} / {stock.item_number} +
+
+ {stock.spec && {stock.spec}} + {stock.warehouse_name} + {stock.location_code && · {stock.location_code}} +
+
+ +
+ +
+ ); +} + +// ===== Destination Warehouse Modal ===== + +function DestWarehouseModal({ + open, + warehouses, + selectedCode, + onClose, + onSelect, +}: { + open: boolean; + warehouses: WarehouseOption[]; + selectedCode: string | null; + onClose: () => void; + onSelect: (w: WarehouseOption) => void; +}) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

도착창고 선택

+ +
+
+ {warehouses.length === 0 ? ( +
+ 등록된 창고가 없습니다 +
+ ) : ( + warehouses.map((w) => { + const active = selectedCode === w.warehouse_code; + return ( + + ); + }) + )} +
+
+
+ ); +} + +// ===== Queue Card ===== + +function QueueCard({ + row, + destName, + onRemove, +}: { + row: QueueRow; + destName: string | null; + onRemove: () => void; +}) { + return ( +
+
+
+ {row.item_code} {row.item_name} +
+
+
+ {row.from_warehouse_name} + + + + + {destName || "도착 미선택"} + +
+
+ + {row.qty.toLocaleString()} + + EA +
+
+
+ +
+ ); +} + +// ===== Confirm Dialog ===== + +function ConfirmDialog({ + message, + onCancel, + onConfirm, +}: { + message: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
e.stopPropagation()} + > +

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_30/pop/inventory/page.tsx b/frontend/app/(main)/COMPANY_30/pop/inventory/page.tsx index 3fbe4f18..98d2c83f 100644 --- a/frontend/app/(main)/COMPANY_30/pop/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_30/pop/inventory/page.tsx @@ -61,6 +61,10 @@ function InventoryHome() { router.push(companyPath("/pop/inventory/adjust")); return; } + if (item.id === "move") { + router.push(companyPath("/pop/inventory/move")); + return; + } }; return ( diff --git a/frontend/app/(main)/COMPANY_7/pop/POP.md b/frontend/app/(main)/COMPANY_7/pop/POP.md index d5e8d3c9..5c9e3e4e 100644 --- a/frontend/app/(main)/COMPANY_7/pop/POP.md +++ b/frontend/app/(main)/COMPANY_7/pop/POP.md @@ -186,6 +186,45 @@ frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유 ## 작업 로그 +### 2026-05-11 +- **POP 공정실행 접수 모달 — 접수가능량 자동 입력 (7개사 일괄)** + - 배경: 사용자가 매번 0부터 키패드를 두드려야 하는 불편함. 접수가능량(`maxQty`)을 모달 오픈 시점에 디스플레이에 미리 채워두고, 필요 시 C/← 로 지우거나 직접 수정. 분할 접수든 신규 카드든 동일하게 잔량 기준. + - **수정 (7개사 동일, md5 `6d93fc91eaa068e69f6837cbc9e9d74d`)** — `_components/production/AcceptProcessModal.tsx:53-57`: + - 대상: COMPANY_7/8/9/10/16/29/30 + - useEffect: `setQty("0")` → `setQty(String(maxQty))`, 의존성 배열에 `maxQty` 추가 (잔량 변동 시 재반영 보장) + - useState 초기값(`"0"`) / KEYS 배열 / handleKey / handleConfirm / 모달 레이아웃 모두 그대로 유지 + - **검증**: frontend `tsc --noEmit` 신규 에러 0 (baseline 4317 유지). Playwright UI E2E: + - COMPANY_7 풀시연 (제조반_계량 필터): + - CODE-00012 (지시 1,500 / 잔량 1,500, 신규 카드) → 모달 헤더 `최대 1,500 EA` + readonly input value `1,500` 자동 입력 확인 + - CODE-00016 (지시 10,000 / 잔량 9,889, 분할 접수 후) → 모달 헤더 `최대 9,889 EA` + readonly input value `9,889` 자동 입력 확인 + - COMPANY_8 풀시연 (URL 진입, 백엔드 컨텍스트 7 공유): CODE-00012 → input `1,500` + 헤더 `최대 1,500 EA` 자동 입력 확인 + - COMPANY_16 풀시연 (회사전환, 하이큐마그): CODE-00002 (지시 10 / 잔량 8) → input `8` + 헤더 `최대 8 EA` 자동 입력 확인 + - COMPANY_29 풀시연 (회사전환, 시연용 회사): WI-DEMO-001 (지시 30 / 잔량 30) → input `30` + 헤더 `최대 30 EA` 자동 입력 확인 + - COMPANY_9/10/30 (회사전환, 제일그라스/큐앤씨/중앙안전유리): 페이지 진입 + 콘솔 에러 0 확인. 접수가능 데이터 0건이라 모달 클릭 미실시 (코드 md5 동일이라 동작은 컴포넌트 레벨 보장) + +- **POP 재고이동 — API 풀연결 (7개사 일괄, COMPANY_7 풀시연 + 6개사 진입검증) + inout-manage 카드 2건 분리** + - 배경: 재고이동 화면 mock 단계에서 풀 API 연동으로 전환. 사용자 정의 시나리오: 출발 칩 동적 렌더 + 좌우 스크롤 / 카드 그리드 / 수량 일부 이동 키패드 / 큐 자동 임시저장 / 도착창고 변경·출발칩 변경 시 큐 초기화 경고 / 큐 1 stock_id = 1행 / 큐 출발·도착 각 1개 고정 / 동일창고 이동은 렉구조(`warehouse_location`) 등록 시만 허용. 도착창고 UI/공정 탭/이동내역 라우팅은 다음 세션. + - **백엔드 신규** [popInventoryMoveController.ts](../../../../backend-node/src/controllers/popInventoryMoveController.ts): 4개 엔드포인트 — stock-list (출발창고+키워드 필터, `current_qty>0` only) / commit (트랜잭션: 출발 차감 + 도착 UPSERT + `inventory_history` 2건 INSERT `transaction_type='이동'` + cart_items saved→cancelled) / temp-load / temp-save. commit 가드: qty>0 / qty<=출발.current_qty / 출발==도착 동일 location 차단 / 출발창고==도착창고 다른 location 은 `warehouse_location` 등록 시만 허용 / 출발창고!=도착창고 무조건 허용. 재고조정 위치불일치 컨트롤러 패턴 그대로 베이스. + - **백엔드 라우트** [popInventoryRoutes.ts](../../../../backend-node/src/routes/popInventoryRoutes.ts): `/pop/inventory/move/{stock-list, commit, temp-load, temp-save}` 4개 추가. + - **백엔드 inout-manage SQL 변경** [popInOutHistoryController.ts:128-130](../../../../backend-node/src/controllers/popInOutHistoryController.ts#L128-L130): inventory_history 분기의 `NOT (transaction_type='이동' AND quantity<0)` 필터 제거. 이전에는 도착(+) row 1건만 노출, 이제 출발(-) + 도착(+) 양쪽 row 다 노출. KPI 이동/전체 카운트 2배. + - **프론트 API client 신규** [popInventoryMove.ts](../../../../frontend/lib/api/popInventoryMove.ts): MoveStockItem / MoveCommitItem 타입 + 함수 4개. + - **프론트 page.tsx 전면 리라이트** [inventory/move/page.tsx](inventory/move/page.tsx) (7개사 md5 동일 `7aabd23a...`): mock 제거 + 6개 핸들러 API 연결 + 마운트 시 loadTempMove 큐+도착창고 복원 + 큐 변경 debounce 400ms saveTempMove + 출발칩/도착창고 변경 시 큐 비우기 경고 ConfirmDialog + 카드 added 상태 큐 stock_id 기반 동적 계산. 공정 탭 disabled (다음 세션). 이동내역 버튼 TODO 유지. + - **프론트 inout-manage 카드 분리** [inout-manage/page.tsx](inventory/inout-manage/page.tsx) (7개사 md5 동일 `3fcf872d...`): `signedQty` 헬퍼 조건문에 `|| record.type === "이동"` 추가 → 이동 row 도 signed_qty 사용. RecordCard qty span 클래스 분기 추가 → `record.type === "이동"` + sq<0=빨강 / sq>0=초록. 출발 row 카드 "이동 맹동창고 -100EA" / 도착 row 카드 "이동 외주창고 +100EA" 분리 표시. + - **프론트 inventory/page.tsx 라우팅 일괄** 7개사 md5 동일 `35dcfaf6...`: 6개사(COMPANY_8/9/10/16/29/30) 의 `handleManageClick` 에 `id==="move"` 분기 추가 (COMPANY_7 동일 복사). 이전에는 `// TODO: move 라우팅 연결 예정` 주석으로 미연결 상태였음. + - **검증**: backend `npm run build` PASS / frontend `tsc --noEmit` baseline 4317 유지 (신규 에러 0). COMPANY_7 풀시연: 맹동창고 1-2620 100EA → 외주창고 이동 → `inventory_stock` 맹동 row 차감 + 외주 row 신규 INSERT + `inventory_history` 2건 INSERT 확인 + inout-manage 화면 카드 2건 (-100/+100) 분리 노출 확인. KPI "이동 2 / 전체 2" 정합. COMPANY_8/9/10/16/29/30 페이지 진입 + 콘솔 에러 0 베이스라인 확인. 풀시나리오 (이동확정+inout-manage) 는 7번에서만 실데이터 검증. + - **미반영 / 후속 작업**: ① 공정 탭 (실적 등록 수량 데이터 소스 미정) ② 이동내역 버튼 라우팅 (별도 화면 vs inout-manage transfer 프리필터) ③ 도착 location 선택 UI (같은 창고 이동 활성화 필수) ④ 키패드 모달 / 새로고침 후 임시저장 복원 / 음수·동일창고 가드 백엔드는 박혀있으나 브라우저 시나리오 검증 미실시. + +- **POP 재고이동 — "이동 대기" 영역에 도착창고 선택 모달 추가 (COMPANY_7)** + - 배경: 재고이동 화면(UI mock 단계, `frontend/app/(main)/COMPANY_7/pop/inventory/move/page.tsx`)의 우측 "이동 대기" 패널에 도착창고를 사용자가 명시 선택할 수 있는 트리거 버튼 + 선택 모달 추가. 창고 리스트는 DB에 등록된 실데이터를 호출. + - **수정 (`pop/inventory/move/page.tsx`)**: + - import 추가: `getReceivingWarehouses`, `WarehouseOption` from `@/lib/api/receiving` (같은 폴더의 `adjust/page.tsx` 와 동일 패턴) + - state 추가: `warehouses`, `destWarehouseModalOpen`, `destWarehouse` + - `useEffect` 1회 마운트 시 `getReceivingWarehouses()` 호출 + - "이동 대기" `

` 우측에 도착창고 트리거 버튼 (선택 시 라벨이 창고명으로 갱신) + - `DestWarehouseModal` 컴포넌트 추가 (파일 내 동일 모듈) — `max-w-2xl` (기존 `max-w-md` 대비 1.5배 확장), 카드 영역 `grid grid-cols-2`, 카드 `min-h-[90px] py-[18px]` + - **검증**: Playwright 시각 검증 — 회사전환 → COMPANY_7 → 모달 클릭 시 등록된 창고 3건 (맹동창고/외주창고/테스트) 한 줄 2개 grid 노출, 카드 선택 시 트리거 버튼 라벨 "외주창고"로 갱신, 모달 자동 닫힘. tsc 통과 + - **스코프**: UI + 창고 리스트 조회만. 큐 카드/이동 확정 등 비즈니스 로직과는 미연결 (mock 단계 유지) + ### 2026-05-08 - **POP 입출고관리 — 사유 컬럼 분리 + 수량 부호 + KPI 조정 카드 (7개 회사 일괄)** - 배경: 기존 카테고리 컬럼에 `재고조정 (사유, sys→act, 차이:N)` 형태로 합성 표시되던 것을 사유는 별도 배지로 분리하고, 수량은 부호로 직관 표시. POP 사용자가 자주 쓰는 조정 동작을 별도 KPI 필터 카드로 분리. diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/common/PopShell.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopShell.tsx index ea471b32..841042a1 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/common/PopShell.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/common/PopShell.tsx @@ -105,7 +105,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false, if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch {} } - router.push("/"); + router.push("/main"); }; const handlePopHome = () => { diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx index 3980a595..14a2f6af 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/AcceptProcessModal.tsx @@ -52,9 +52,9 @@ export function AcceptProcessModal({ useEffect(() => { if (open) { - setQty("0"); + setQty(String(maxQty)); } - }, [open]); + }, [open, maxQty]); const qtyNum = parseInt(qty, 10) || 0; const isOverMax = qtyNum > maxQty; diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx index 08cb0a7e..2464bf49 100644 --- a/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/ProcessWork.tsx @@ -884,6 +884,28 @@ export function ProcessWork({ [groups, selectedGroupId], ); + // Phase 종료(타이머 complete) 전이 시 다음 phase 첫 그룹으로 자동 이동 + const prevPhaseStatusRef = useRef | null>(null); + useEffect(() => { + const next: Record = {}; + for (const phase of availablePhases) { + next[phase] = phaseTimerMap[phase]?.status ?? "idle"; + } + const prev = prevPhaseStatusRef.current; + prevPhaseStatusRef.current = next; + if (prev === null) return; + if (!selectedGroup) return; + const cur = selectedGroup.phase; + if (next[cur] !== "completed") return; + if (!["idle", "running", "paused"].includes(prev[cur] ?? "")) return; + const idx = availablePhases.indexOf(cur); + const nextPhase = availablePhases[idx + 1]; + if (!nextPhase) return; + const grps = groupsByPhase[nextPhase]; + if (!grps || grps.length === 0) return; + setSelectedGroupId(grps[0].itemId); + }, [phaseTimerMap, availablePhases, selectedGroup, groupsByPhase]); + const currentItems = useMemo( () => selectedGroup?.items.sort( @@ -1795,13 +1817,15 @@ export function ProcessWork({ 정지 +
+ + + +
+

재고이동

+

출발 창고에서 도착 창고로 품목을 이동합니다

+
+
+ + + + + + {/* ===== Body Grid ===== */} +
+ {/* ===== Left: Tabs + Filter + Cards ===== */} +
+ {/* Source Tabs (창고 / 공정) */} +
+
+ setSourceTab("warehouse")} /> + setSourceTab("process")} disabled /> +
+
+ + {/* Filter */} +
+ {/* Warehouse Chips — 좌우 스크롤 */} +
+ {fromChips.map((c) => { + const active = selectedFromCode === c.code; + return ( + + ); + })} +
+ + {/* Search Bar */} +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") setAppliedKeyword(keyword.trim()); }} + placeholder="품목명 / 코드 검색" + className="flex-1 px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-orange-400 bg-white" + /> + +
+
+ + {/* Stock Cards Grid */} +
+ {sourceTab === "process" ? ( +
+ 공정 출발은 다음 세션에서 작업 예정입니다 +
+ ) : stocksLoading ? ( +
+ 불러오는 중... +
+ ) : stocks.length === 0 ? ( +
+ 재고가 없습니다 +
+ ) : ( + stocks.map((s) => { + const queued = queueIds.has(s.id); + const queuedRow = queue.find((q) => q.stock_id === s.id); + return ( + addToQueue(s, parseFloat(s.current_qty) || 0)} + onQtyClick={() => openNumpad(s)} + /> + ); + }) + )} +
+
+ + {/* ===== Right: Move Queue Panel ===== */} +
+
+
+

이동 대기

+ +
+ + {queueCount}건 + +
+ +
+ {queue.length === 0 ? ( +
+ 선택된 항목이 없습니다 +
+ ) : ( + queue.map((r) => ( + removeFromQueue(r.stock_id)} + /> + )) + )} +
+ + +
+
+ + {/* ===== Destination Warehouse Modal ===== */} + setDestModalOpen(false)} + onSelect={handleDestSelect} + /> + + {/* ===== Numpad Modal ===== */} + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget ? parseFloat(numpadTarget.current_qty) || 0 : 0} + itemName={numpadTarget?.item_name ?? ""} + itemNumber={numpadTarget?.item_number ?? ""} + initialQty={ + numpadTarget + ? (queue.find((q) => q.stock_id === numpadTarget.id)?.qty ?? + parseFloat(numpadTarget.current_qty) ?? 0) + : 0 + } + /> + + {/* ===== Warning Dialog (출발/도착 변경) ===== */} + {pendingChange && ( + setPendingChange(null)} + onConfirm={confirmPendingChange} + /> + )} + + ); +} + +// ===== Source Tab Button ===== + +function SourceTabButton({ + label, + active, + onClick, + disabled, +}: { + label: string; + active: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ===== Stock Item Card ===== + +function StockItemCard({ + stock, + queued, + queuedQty, + onAdd, + onQtyClick, +}: { + stock: MoveStockItem; + queued: boolean; + queuedQty: number | undefined; + onAdd: () => void; + onQtyClick: () => void; +}) { + const stockQty = parseFloat(stock.current_qty) || 0; + const displayQty = queued && queuedQty !== undefined ? queuedQty : stockQty; + return ( +
+
+
+
+ {stock.item_name} / {stock.item_number} +
+
+ {stock.spec && {stock.spec}} + {stock.warehouse_name} + {stock.location_code && · {stock.location_code}} +
+
+ +
+ +
+ ); +} + +// ===== Destination Warehouse Modal ===== + +function DestWarehouseModal({ + open, + warehouses, + selectedCode, + onClose, + onSelect, +}: { + open: boolean; + warehouses: WarehouseOption[]; + selectedCode: string | null; + onClose: () => void; + onSelect: (w: WarehouseOption) => void; +}) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

도착창고 선택

+ +
+
+ {warehouses.length === 0 ? ( +
+ 등록된 창고가 없습니다 +
+ ) : ( + warehouses.map((w) => { + const active = selectedCode === w.warehouse_code; + return ( + + ); + }) + )} +
+
+
+ ); +} + +// ===== Queue Card ===== + +function QueueCard({ + row, + destName, + onRemove, +}: { + row: QueueRow; + destName: string | null; + onRemove: () => void; +}) { + return ( +
+
+
+ {row.item_code} {row.item_name} +
+
+
+ {row.from_warehouse_name} + + + + + {destName || "도착 미선택"} + +
+
+ + {row.qty.toLocaleString()} + + EA +
+
+
+ +
+ ); +} + +// ===== Confirm Dialog ===== + +function ConfirmDialog({ + message, + onCancel, + onConfirm, +}: { + message: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
e.stopPropagation()} + > +

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_7/pop/inventory/page.tsx b/frontend/app/(main)/COMPANY_7/pop/inventory/page.tsx index a4e5cf6d..98d2c83f 100644 --- a/frontend/app/(main)/COMPANY_7/pop/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_7/pop/inventory/page.tsx @@ -61,7 +61,10 @@ function InventoryHome() { router.push(companyPath("/pop/inventory/adjust")); return; } - // TODO: move 라우팅 연결 예정 + if (item.id === "move") { + router.push(companyPath("/pop/inventory/move")); + return; + } }; return ( diff --git a/frontend/app/(main)/COMPANY_8/pop/_components/common/PopShell.tsx b/frontend/app/(main)/COMPANY_8/pop/_components/common/PopShell.tsx index 94df6e12..69989748 100644 --- a/frontend/app/(main)/COMPANY_8/pop/_components/common/PopShell.tsx +++ b/frontend/app/(main)/COMPANY_8/pop/_components/common/PopShell.tsx @@ -105,7 +105,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false, if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch {} } - router.push("/"); + router.push("/main"); }; const handlePopHome = () => { diff --git a/frontend/app/(main)/COMPANY_8/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_8/pop/_components/production/AcceptProcessModal.tsx index 3980a595..14a2f6af 100644 --- a/frontend/app/(main)/COMPANY_8/pop/_components/production/AcceptProcessModal.tsx +++ b/frontend/app/(main)/COMPANY_8/pop/_components/production/AcceptProcessModal.tsx @@ -52,9 +52,9 @@ export function AcceptProcessModal({ useEffect(() => { if (open) { - setQty("0"); + setQty(String(maxQty)); } - }, [open]); + }, [open, maxQty]); const qtyNum = parseInt(qty, 10) || 0; const isOverMax = qtyNum > maxQty; diff --git a/frontend/app/(main)/COMPANY_8/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_8/pop/_components/production/ProcessWork.tsx index 08cb0a7e..2464bf49 100644 --- a/frontend/app/(main)/COMPANY_8/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_8/pop/_components/production/ProcessWork.tsx @@ -884,6 +884,28 @@ export function ProcessWork({ [groups, selectedGroupId], ); + // Phase 종료(타이머 complete) 전이 시 다음 phase 첫 그룹으로 자동 이동 + const prevPhaseStatusRef = useRef | null>(null); + useEffect(() => { + const next: Record = {}; + for (const phase of availablePhases) { + next[phase] = phaseTimerMap[phase]?.status ?? "idle"; + } + const prev = prevPhaseStatusRef.current; + prevPhaseStatusRef.current = next; + if (prev === null) return; + if (!selectedGroup) return; + const cur = selectedGroup.phase; + if (next[cur] !== "completed") return; + if (!["idle", "running", "paused"].includes(prev[cur] ?? "")) return; + const idx = availablePhases.indexOf(cur); + const nextPhase = availablePhases[idx + 1]; + if (!nextPhase) return; + const grps = groupsByPhase[nextPhase]; + if (!grps || grps.length === 0) return; + setSelectedGroupId(grps[0].itemId); + }, [phaseTimerMap, availablePhases, selectedGroup, groupsByPhase]); + const currentItems = useMemo( () => selectedGroup?.items.sort( @@ -1795,13 +1817,15 @@ export function ProcessWork({ 정지 +
+ + + +
+

재고이동

+

출발 창고에서 도착 창고로 품목을 이동합니다

+
+
+ + + + + + {/* ===== Body Grid ===== */} +
+ {/* ===== Left: Tabs + Filter + Cards ===== */} +
+ {/* Source Tabs (창고 / 공정) */} +
+
+ setSourceTab("warehouse")} /> + setSourceTab("process")} disabled /> +
+
+ + {/* Filter */} +
+ {/* Warehouse Chips — 좌우 스크롤 */} +
+ {fromChips.map((c) => { + const active = selectedFromCode === c.code; + return ( + + ); + })} +
+ + {/* Search Bar */} +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") setAppliedKeyword(keyword.trim()); }} + placeholder="품목명 / 코드 검색" + className="flex-1 px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-orange-400 bg-white" + /> + +
+
+ + {/* Stock Cards Grid */} +
+ {sourceTab === "process" ? ( +
+ 공정 출발은 다음 세션에서 작업 예정입니다 +
+ ) : stocksLoading ? ( +
+ 불러오는 중... +
+ ) : stocks.length === 0 ? ( +
+ 재고가 없습니다 +
+ ) : ( + stocks.map((s) => { + const queued = queueIds.has(s.id); + const queuedRow = queue.find((q) => q.stock_id === s.id); + return ( + addToQueue(s, parseFloat(s.current_qty) || 0)} + onQtyClick={() => openNumpad(s)} + /> + ); + }) + )} +
+
+ + {/* ===== Right: Move Queue Panel ===== */} +
+
+
+

이동 대기

+ +
+ + {queueCount}건 + +
+ +
+ {queue.length === 0 ? ( +
+ 선택된 항목이 없습니다 +
+ ) : ( + queue.map((r) => ( + removeFromQueue(r.stock_id)} + /> + )) + )} +
+ + +
+
+ + {/* ===== Destination Warehouse Modal ===== */} + setDestModalOpen(false)} + onSelect={handleDestSelect} + /> + + {/* ===== Numpad Modal ===== */} + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget ? parseFloat(numpadTarget.current_qty) || 0 : 0} + itemName={numpadTarget?.item_name ?? ""} + itemNumber={numpadTarget?.item_number ?? ""} + initialQty={ + numpadTarget + ? (queue.find((q) => q.stock_id === numpadTarget.id)?.qty ?? + parseFloat(numpadTarget.current_qty) ?? 0) + : 0 + } + /> + + {/* ===== Warning Dialog (출발/도착 변경) ===== */} + {pendingChange && ( + setPendingChange(null)} + onConfirm={confirmPendingChange} + /> + )} + + ); +} + +// ===== Source Tab Button ===== + +function SourceTabButton({ + label, + active, + onClick, + disabled, +}: { + label: string; + active: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ===== Stock Item Card ===== + +function StockItemCard({ + stock, + queued, + queuedQty, + onAdd, + onQtyClick, +}: { + stock: MoveStockItem; + queued: boolean; + queuedQty: number | undefined; + onAdd: () => void; + onQtyClick: () => void; +}) { + const stockQty = parseFloat(stock.current_qty) || 0; + const displayQty = queued && queuedQty !== undefined ? queuedQty : stockQty; + return ( +
+
+
+
+ {stock.item_name} / {stock.item_number} +
+
+ {stock.spec && {stock.spec}} + {stock.warehouse_name} + {stock.location_code && · {stock.location_code}} +
+
+ +
+ +
+ ); +} + +// ===== Destination Warehouse Modal ===== + +function DestWarehouseModal({ + open, + warehouses, + selectedCode, + onClose, + onSelect, +}: { + open: boolean; + warehouses: WarehouseOption[]; + selectedCode: string | null; + onClose: () => void; + onSelect: (w: WarehouseOption) => void; +}) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

도착창고 선택

+ +
+
+ {warehouses.length === 0 ? ( +
+ 등록된 창고가 없습니다 +
+ ) : ( + warehouses.map((w) => { + const active = selectedCode === w.warehouse_code; + return ( + + ); + }) + )} +
+
+
+ ); +} + +// ===== Queue Card ===== + +function QueueCard({ + row, + destName, + onRemove, +}: { + row: QueueRow; + destName: string | null; + onRemove: () => void; +}) { + return ( +
+
+
+ {row.item_code} {row.item_name} +
+
+
+ {row.from_warehouse_name} + + + + + {destName || "도착 미선택"} + +
+
+ + {row.qty.toLocaleString()} + + EA +
+
+
+ +
+ ); +} + +// ===== Confirm Dialog ===== + +function ConfirmDialog({ + message, + onCancel, + onConfirm, +}: { + message: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
e.stopPropagation()} + > +

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_8/pop/inventory/page.tsx b/frontend/app/(main)/COMPANY_8/pop/inventory/page.tsx index 3fbe4f18..98d2c83f 100644 --- a/frontend/app/(main)/COMPANY_8/pop/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_8/pop/inventory/page.tsx @@ -61,6 +61,10 @@ function InventoryHome() { router.push(companyPath("/pop/inventory/adjust")); return; } + if (item.id === "move") { + router.push(companyPath("/pop/inventory/move")); + return; + } }; return ( diff --git a/frontend/app/(main)/COMPANY_9/pop/_components/common/PopShell.tsx b/frontend/app/(main)/COMPANY_9/pop/_components/common/PopShell.tsx index cf1429ed..2f7c0dbc 100644 --- a/frontend/app/(main)/COMPANY_9/pop/_components/common/PopShell.tsx +++ b/frontend/app/(main)/COMPANY_9/pop/_components/common/PopShell.tsx @@ -105,7 +105,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false, if (document.fullscreenElement) { try { await document.exitFullscreen(); } catch {} } - router.push("/"); + router.push("/main"); }; const handlePopHome = () => { diff --git a/frontend/app/(main)/COMPANY_9/pop/_components/production/AcceptProcessModal.tsx b/frontend/app/(main)/COMPANY_9/pop/_components/production/AcceptProcessModal.tsx index 3980a595..14a2f6af 100644 --- a/frontend/app/(main)/COMPANY_9/pop/_components/production/AcceptProcessModal.tsx +++ b/frontend/app/(main)/COMPANY_9/pop/_components/production/AcceptProcessModal.tsx @@ -52,9 +52,9 @@ export function AcceptProcessModal({ useEffect(() => { if (open) { - setQty("0"); + setQty(String(maxQty)); } - }, [open]); + }, [open, maxQty]); const qtyNum = parseInt(qty, 10) || 0; const isOverMax = qtyNum > maxQty; diff --git a/frontend/app/(main)/COMPANY_9/pop/_components/production/ProcessWork.tsx b/frontend/app/(main)/COMPANY_9/pop/_components/production/ProcessWork.tsx index 08cb0a7e..2464bf49 100644 --- a/frontend/app/(main)/COMPANY_9/pop/_components/production/ProcessWork.tsx +++ b/frontend/app/(main)/COMPANY_9/pop/_components/production/ProcessWork.tsx @@ -884,6 +884,28 @@ export function ProcessWork({ [groups, selectedGroupId], ); + // Phase 종료(타이머 complete) 전이 시 다음 phase 첫 그룹으로 자동 이동 + const prevPhaseStatusRef = useRef | null>(null); + useEffect(() => { + const next: Record = {}; + for (const phase of availablePhases) { + next[phase] = phaseTimerMap[phase]?.status ?? "idle"; + } + const prev = prevPhaseStatusRef.current; + prevPhaseStatusRef.current = next; + if (prev === null) return; + if (!selectedGroup) return; + const cur = selectedGroup.phase; + if (next[cur] !== "completed") return; + if (!["idle", "running", "paused"].includes(prev[cur] ?? "")) return; + const idx = availablePhases.indexOf(cur); + const nextPhase = availablePhases[idx + 1]; + if (!nextPhase) return; + const grps = groupsByPhase[nextPhase]; + if (!grps || grps.length === 0) return; + setSelectedGroupId(grps[0].itemId); + }, [phaseTimerMap, availablePhases, selectedGroup, groupsByPhase]); + const currentItems = useMemo( () => selectedGroup?.items.sort( @@ -1795,13 +1817,15 @@ export function ProcessWork({ 정지 +
+ + + +
+

재고이동

+

출발 창고에서 도착 창고로 품목을 이동합니다

+
+
+ + + + + + {/* ===== Body Grid ===== */} +
+ {/* ===== Left: Tabs + Filter + Cards ===== */} +
+ {/* Source Tabs (창고 / 공정) */} +
+
+ setSourceTab("warehouse")} /> + setSourceTab("process")} disabled /> +
+
+ + {/* Filter */} +
+ {/* Warehouse Chips — 좌우 스크롤 */} +
+ {fromChips.map((c) => { + const active = selectedFromCode === c.code; + return ( + + ); + })} +
+ + {/* Search Bar */} +
+ setKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") setAppliedKeyword(keyword.trim()); }} + placeholder="품목명 / 코드 검색" + className="flex-1 px-4 py-2 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-orange-400 bg-white" + /> + +
+
+ + {/* Stock Cards Grid */} +
+ {sourceTab === "process" ? ( +
+ 공정 출발은 다음 세션에서 작업 예정입니다 +
+ ) : stocksLoading ? ( +
+ 불러오는 중... +
+ ) : stocks.length === 0 ? ( +
+ 재고가 없습니다 +
+ ) : ( + stocks.map((s) => { + const queued = queueIds.has(s.id); + const queuedRow = queue.find((q) => q.stock_id === s.id); + return ( + addToQueue(s, parseFloat(s.current_qty) || 0)} + onQtyClick={() => openNumpad(s)} + /> + ); + }) + )} +
+
+ + {/* ===== Right: Move Queue Panel ===== */} +
+
+
+

이동 대기

+ +
+ + {queueCount}건 + +
+ +
+ {queue.length === 0 ? ( +
+ 선택된 항목이 없습니다 +
+ ) : ( + queue.map((r) => ( + removeFromQueue(r.stock_id)} + /> + )) + )} +
+ + +
+
+ + {/* ===== Destination Warehouse Modal ===== */} + setDestModalOpen(false)} + onSelect={handleDestSelect} + /> + + {/* ===== Numpad Modal ===== */} + { setNumpadOpen(false); setNumpadTarget(null); }} + onConfirm={handleNumpadConfirm} + maxQty={numpadTarget ? parseFloat(numpadTarget.current_qty) || 0 : 0} + itemName={numpadTarget?.item_name ?? ""} + itemNumber={numpadTarget?.item_number ?? ""} + initialQty={ + numpadTarget + ? (queue.find((q) => q.stock_id === numpadTarget.id)?.qty ?? + parseFloat(numpadTarget.current_qty) ?? 0) + : 0 + } + /> + + {/* ===== Warning Dialog (출발/도착 변경) ===== */} + {pendingChange && ( + setPendingChange(null)} + onConfirm={confirmPendingChange} + /> + )} + + ); +} + +// ===== Source Tab Button ===== + +function SourceTabButton({ + label, + active, + onClick, + disabled, +}: { + label: string; + active: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ===== Stock Item Card ===== + +function StockItemCard({ + stock, + queued, + queuedQty, + onAdd, + onQtyClick, +}: { + stock: MoveStockItem; + queued: boolean; + queuedQty: number | undefined; + onAdd: () => void; + onQtyClick: () => void; +}) { + const stockQty = parseFloat(stock.current_qty) || 0; + const displayQty = queued && queuedQty !== undefined ? queuedQty : stockQty; + return ( +
+
+
+
+ {stock.item_name} / {stock.item_number} +
+
+ {stock.spec && {stock.spec}} + {stock.warehouse_name} + {stock.location_code && · {stock.location_code}} +
+
+ +
+ +
+ ); +} + +// ===== Destination Warehouse Modal ===== + +function DestWarehouseModal({ + open, + warehouses, + selectedCode, + onClose, + onSelect, +}: { + open: boolean; + warehouses: WarehouseOption[]; + selectedCode: string | null; + onClose: () => void; + onSelect: (w: WarehouseOption) => void; +}) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

도착창고 선택

+ +
+
+ {warehouses.length === 0 ? ( +
+ 등록된 창고가 없습니다 +
+ ) : ( + warehouses.map((w) => { + const active = selectedCode === w.warehouse_code; + return ( + + ); + }) + )} +
+
+
+ ); +} + +// ===== Queue Card ===== + +function QueueCard({ + row, + destName, + onRemove, +}: { + row: QueueRow; + destName: string | null; + onRemove: () => void; +}) { + return ( +
+
+
+ {row.item_code} {row.item_name} +
+
+
+ {row.from_warehouse_name} + + + + + {destName || "도착 미선택"} + +
+
+ + {row.qty.toLocaleString()} + + EA +
+
+
+ +
+ ); +} + +// ===== Confirm Dialog ===== + +function ConfirmDialog({ + message, + onCancel, + onConfirm, +}: { + message: string; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
e.stopPropagation()} + > +

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_9/pop/inventory/page.tsx b/frontend/app/(main)/COMPANY_9/pop/inventory/page.tsx index 3fbe4f18..98d2c83f 100644 --- a/frontend/app/(main)/COMPANY_9/pop/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_9/pop/inventory/page.tsx @@ -61,6 +61,10 @@ function InventoryHome() { router.push(companyPath("/pop/inventory/adjust")); return; } + if (item.id === "move") { + router.push(companyPath("/pop/inventory/move")); + return; + } }; return ( diff --git a/frontend/lib/api/popInventoryMove.ts b/frontend/lib/api/popInventoryMove.ts new file mode 100644 index 00000000..e526dfd7 --- /dev/null +++ b/frontend/lib/api/popInventoryMove.ts @@ -0,0 +1,73 @@ +/** + * popInventoryMove API client + * 신 POP 재고이동 (frontend/app/(main)/COMPANY_X/pop/inventory/move/) + */ + +import { apiClient } from "./client"; + +export interface MoveStockItem { + id: string; // inventory_stock.id + item_code: string; + item_name: string; + item_number: string; + spec: string; + unit: string; + warehouse_code: string; + warehouse_name: string; + location_code: string; + current_qty: string; // text 컬럼 (numeric 변환은 클라이언트가) +} + +export interface MoveStockListParams { + warehouse_code?: string; // 'all' or 창고코드 + keyword?: string; +} + +export interface MoveCommitItem { + stock_id: string; + qty: number; +} + +export interface MoveCommitParams { + items: MoveCommitItem[]; + to_warehouse_code: string; + to_location_code?: string; +} + +export interface MoveCommitResult { + stock_id: string; + status: "ok" | "not_found" | "invalid_qty" | "exceeds_stock" | "same_position" | "no_rack"; + reason?: string; +} + +export interface MoveCommitData { + moveCount: number; + results: MoveCommitResult[]; +} + +export interface TempLoadRow { + id: string; + row_data: string; // JSON 직렬화된 큐 row (서버는 그대로 저장한 string) + row_key: string; + created_date: string; +} + +export async function getMoveStockList(params?: MoveStockListParams) { + const res = await apiClient.get("/pop/inventory/move/stock-list", { params: params || {} }); + return res.data as { success: boolean; data: MoveStockItem[] }; +} + +export async function commitMove(body: MoveCommitParams) { + const res = await apiClient.post("/pop/inventory/move/commit", body); + return res.data as { success: boolean; message?: string; data?: MoveCommitData }; +} + +export async function loadTempMove() { + const res = await apiClient.get("/pop/inventory/move/temp-load"); + return res.data as { success: boolean; data: TempLoadRow[] }; +} + +export async function saveTempMove(items: unknown[]) { + const res = await apiClient.post("/pop/inventory/move/temp-save", { items }); + return res.data as { success: boolean; message?: string }; +}