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({
정지
{adjust ? (
diff --git a/frontend/app/(main)/COMPANY_10/pop/inventory/move/page.tsx b/frontend/app/(main)/COMPANY_10/pop/inventory/move/page.tsx
new file mode 100644
index 00000000..f3796d5a
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_10/pop/inventory/move/page.tsx
@@ -0,0 +1,739 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
+import { SimpleKeypadModal } from "../../_components/common/SimpleKeypadModal";
+import { getReceivingWarehouses, type WarehouseOption } from "@/lib/api/receiving";
+import {
+ getMoveStockList,
+ commitMove,
+ loadTempMove,
+ saveTempMove,
+ type MoveStockItem,
+} from "@/lib/api/popInventoryMove";
+
+// ===== Types =====
+
+interface QueueRow {
+ stock_id: string;
+ item_code: string;
+ item_name: string;
+ item_number: string;
+ from_warehouse_code: string;
+ from_warehouse_name: string;
+ from_location_code: string;
+ qty: number;
+ // 도착창고 마커 (임시저장 복원용, 모든 큐 row 가 동일 값)
+ dest_warehouse_code: string | null;
+ dest_warehouse_name: string | null;
+}
+
+type SourceTab = "warehouse" | "process";
+
+interface PendingChange {
+ kind: "fromWarehouse" | "destWarehouse";
+ value: string | WarehouseOption | null;
+}
+
+const TEMP_SAVE_DEBOUNCE_MS = 400;
+const ALL_CODE = "all";
+
+// ===== Page =====
+
+export default function InventoryMovePage() {
+ const router = useRouter();
+ const companyPath = usePopCompanyPath();
+
+ // 좌측
+ const [sourceTab, setSourceTab] = useState("warehouse");
+ const [warehouses, setWarehouses] = useState([]);
+ const [selectedFromCode, setSelectedFromCode] = useState(ALL_CODE);
+ const [keyword, setKeyword] = useState("");
+ const [appliedKeyword, setAppliedKeyword] = useState("");
+ const [stocks, setStocks] = useState([]);
+ const [stocksLoading, setStocksLoading] = useState(false);
+
+ // 키패드
+ const [numpadOpen, setNumpadOpen] = useState(false);
+ const [numpadTarget, setNumpadTarget] = useState(null);
+
+ // 우측 큐
+ const [queue, setQueue] = useState([]);
+ const [destWarehouse, setDestWarehouse] = useState(null);
+ const [destModalOpen, setDestModalOpen] = useState(false);
+
+ // 경고 다이얼로그 (출발/도착 변경 시)
+ const [pendingChange, setPendingChange] = useState(null);
+
+ // 확정 중
+ const [committing, setCommitting] = useState(false);
+
+ // 임시저장 debounce
+ const saveTimerRef = useRef | null>(null);
+ const skipNextSaveRef = useRef(false);
+
+ // 마운트: 창고 + 임시저장 복원
+ useEffect(() => {
+ (async () => {
+ try {
+ const whRes = await getReceivingWarehouses();
+ if (whRes.success) setWarehouses(whRes.data);
+ } catch {
+ /* ignore */
+ }
+ try {
+ const tempRes = await loadTempMove();
+ if (tempRes.success && tempRes.data.length > 0) {
+ const parsed: QueueRow[] = [];
+ for (const t of tempRes.data) {
+ try {
+ const row = JSON.parse(t.row_data) as QueueRow;
+ if (row && row.stock_id) parsed.push(row);
+ } catch {
+ /* ignore broken row */
+ }
+ }
+ if (parsed.length > 0) {
+ skipNextSaveRef.current = true;
+ setQueue(parsed);
+ const first = parsed[0];
+ if (first.dest_warehouse_code && first.dest_warehouse_name) {
+ setDestWarehouse({
+ warehouse_code: first.dest_warehouse_code,
+ warehouse_name: first.dest_warehouse_name,
+ } as WarehouseOption);
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ })();
+ }, []);
+
+ // 재고 목록 fetch
+ const fetchStocks = useCallback(async (warehouseCode: string, kw: string) => {
+ setStocksLoading(true);
+ try {
+ const res = await getMoveStockList({ warehouse_code: warehouseCode, keyword: kw || undefined });
+ if (res.success) setStocks(res.data);
+ } catch {
+ setStocks([]);
+ } finally {
+ setStocksLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (sourceTab !== "warehouse") return;
+ fetchStocks(selectedFromCode, appliedKeyword);
+ }, [sourceTab, selectedFromCode, appliedKeyword, fetchStocks]);
+
+ // 큐 변경 시 자동 임시저장 (debounce)
+ useEffect(() => {
+ if (skipNextSaveRef.current) {
+ skipNextSaveRef.current = false;
+ return;
+ }
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => {
+ saveTempMove(queue).catch(() => {});
+ }, TEMP_SAVE_DEBOUNCE_MS);
+ return () => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ };
+ }, [queue]);
+
+ // 큐에 추가
+ const addToQueue = useCallback((stock: MoveStockItem, qty: number) => {
+ const stockQty = parseFloat(stock.current_qty) || 0;
+ const finalQty = Math.min(Math.max(qty, 1), stockQty);
+ setQueue((prev) => {
+ const exists = prev.find((q) => q.stock_id === stock.id);
+ if (exists) {
+ return prev.map((q) =>
+ q.stock_id === stock.id ? { ...q, qty: finalQty } : q
+ );
+ }
+ return [
+ ...prev,
+ {
+ stock_id: stock.id,
+ item_code: stock.item_code,
+ item_name: stock.item_name,
+ item_number: stock.item_number,
+ from_warehouse_code: stock.warehouse_code,
+ from_warehouse_name: stock.warehouse_name,
+ from_location_code: stock.location_code || "",
+ qty: finalQty,
+ dest_warehouse_code: destWarehouse?.warehouse_code ?? null,
+ dest_warehouse_name: destWarehouse?.warehouse_name ?? null,
+ },
+ ];
+ });
+ }, [destWarehouse]);
+
+ const removeFromQueue = useCallback((stock_id: string) => {
+ setQueue((prev) => prev.filter((q) => q.stock_id !== stock_id));
+ }, []);
+
+ // 큐가 비어있지 않을 때 출발창고 변경 → 경고
+ const handleFromChipClick = (code: string) => {
+ if (code === selectedFromCode) return;
+ if (queue.length > 0) {
+ setPendingChange({ kind: "fromWarehouse", value: code });
+ return;
+ }
+ setSelectedFromCode(code);
+ };
+
+ // 도착창고 모달 선택
+ const handleDestSelect = (w: WarehouseOption) => {
+ if (destWarehouse && w.warehouse_code === destWarehouse.warehouse_code) {
+ setDestModalOpen(false);
+ return;
+ }
+ if (queue.length > 0 && destWarehouse) {
+ setPendingChange({ kind: "destWarehouse", value: w });
+ setDestModalOpen(false);
+ return;
+ }
+ setDestWarehouse(w);
+ setDestModalOpen(false);
+ };
+
+ // 다이얼로그 확인 → 큐 비우고 변경 적용
+ const confirmPendingChange = () => {
+ if (!pendingChange) return;
+ setQueue([]);
+ if (pendingChange.kind === "fromWarehouse") {
+ setSelectedFromCode(pendingChange.value as string);
+ } else if (pendingChange.kind === "destWarehouse") {
+ setDestWarehouse(pendingChange.value as WarehouseOption);
+ }
+ setPendingChange(null);
+ };
+
+ // 키패드
+ const openNumpad = (stock: MoveStockItem) => {
+ setNumpadTarget(stock);
+ setNumpadOpen(true);
+ };
+ const handleNumpadConfirm = (qty: number) => {
+ if (!numpadTarget) return;
+ addToQueue(numpadTarget, qty);
+ setNumpadTarget(null);
+ setNumpadOpen(false);
+ };
+
+ // 이동 확정
+ const handleCommit = async () => {
+ if (committing) return;
+ if (queue.length === 0) return;
+ if (!destWarehouse) return;
+ setCommitting(true);
+ try {
+ const res = await commitMove({
+ items: queue.map((q) => ({ stock_id: q.stock_id, qty: q.qty })),
+ to_warehouse_code: destWarehouse.warehouse_code,
+ });
+ if (res.success) {
+ const failed = (res.data?.results || []).filter((r) => r.status !== "ok");
+ if (failed.length > 0) {
+ const msg = failed
+ .map((f) => `${f.stock_id}: ${f.reason || f.status}`)
+ .join("\n");
+ alert(`일부 항목 실패\n\n${msg}`);
+ }
+ setQueue([]);
+ fetchStocks(selectedFromCode, appliedKeyword);
+ } else {
+ alert(res.message || "이동 확정 실패");
+ }
+ } catch (e: any) {
+ alert(`이동 확정 오류: ${e?.message || e}`);
+ } finally {
+ setCommitting(false);
+ }
+ };
+
+ // ===== Derived =====
+
+ const queueIds = useMemo(() => new Set(queue.map((q) => q.stock_id)), [queue]);
+ const queueCount = queue.length;
+
+ // 출발 칩 옵션: "전체" + 등록된 창고 전체
+ const fromChips: Array<{ code: string; name: string }> = useMemo(() => {
+ return [
+ { code: ALL_CODE, name: "전체" },
+ ...warehouses.map((w) => ({ code: w.warehouse_code, name: w.warehouse_name })),
+ ];
+ }, [warehouses]);
+
+ return (
+
+ {/* ===== Back + Title + History Button ===== */}
+
+
+
+
+
+
+
재고이동
+
출발 창고에서 도착 창고로 품목을 이동합니다
+
+
+
+
+
+
+
+ {/* ===== 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({
정지
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
@@ -1823,13 +1847,15 @@ export function ProcessWork({
재개
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
diff --git a/frontend/app/(main)/COMPANY_29/pop/inventory/inout-manage/page.tsx b/frontend/app/(main)/COMPANY_29/pop/inventory/inout-manage/page.tsx
index e4dd0337..24786994 100644
--- a/frontend/app/(main)/COMPANY_29/pop/inventory/inout-manage/page.tsx
+++ b/frontend/app/(main)/COMPANY_29/pop/inventory/inout-manage/page.tsx
@@ -125,7 +125,7 @@ function extractReason(record: InOutRow): string {
// type 은 세분화된 값(구매입고/판매출고 등)이라 direction 기반 분기 사용
function signedQty(record: InOutRow, qty: number): number {
if (qty === 0) return 0;
- if (record.type === "조정") {
+ if (record.type === "조정" || record.type === "이동") {
const sq = Number(record.signed_qty);
if (Number.isFinite(sq)) return sq;
return qty;
@@ -690,7 +690,12 @@ function RecordCard({
{warehouse}
{adjust ? (
diff --git a/frontend/app/(main)/COMPANY_29/pop/inventory/move/page.tsx b/frontend/app/(main)/COMPANY_29/pop/inventory/move/page.tsx
new file mode 100644
index 00000000..f3796d5a
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_29/pop/inventory/move/page.tsx
@@ -0,0 +1,739 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
+import { SimpleKeypadModal } from "../../_components/common/SimpleKeypadModal";
+import { getReceivingWarehouses, type WarehouseOption } from "@/lib/api/receiving";
+import {
+ getMoveStockList,
+ commitMove,
+ loadTempMove,
+ saveTempMove,
+ type MoveStockItem,
+} from "@/lib/api/popInventoryMove";
+
+// ===== Types =====
+
+interface QueueRow {
+ stock_id: string;
+ item_code: string;
+ item_name: string;
+ item_number: string;
+ from_warehouse_code: string;
+ from_warehouse_name: string;
+ from_location_code: string;
+ qty: number;
+ // 도착창고 마커 (임시저장 복원용, 모든 큐 row 가 동일 값)
+ dest_warehouse_code: string | null;
+ dest_warehouse_name: string | null;
+}
+
+type SourceTab = "warehouse" | "process";
+
+interface PendingChange {
+ kind: "fromWarehouse" | "destWarehouse";
+ value: string | WarehouseOption | null;
+}
+
+const TEMP_SAVE_DEBOUNCE_MS = 400;
+const ALL_CODE = "all";
+
+// ===== Page =====
+
+export default function InventoryMovePage() {
+ const router = useRouter();
+ const companyPath = usePopCompanyPath();
+
+ // 좌측
+ const [sourceTab, setSourceTab] = useState("warehouse");
+ const [warehouses, setWarehouses] = useState([]);
+ const [selectedFromCode, setSelectedFromCode] = useState(ALL_CODE);
+ const [keyword, setKeyword] = useState("");
+ const [appliedKeyword, setAppliedKeyword] = useState("");
+ const [stocks, setStocks] = useState([]);
+ const [stocksLoading, setStocksLoading] = useState(false);
+
+ // 키패드
+ const [numpadOpen, setNumpadOpen] = useState(false);
+ const [numpadTarget, setNumpadTarget] = useState(null);
+
+ // 우측 큐
+ const [queue, setQueue] = useState([]);
+ const [destWarehouse, setDestWarehouse] = useState(null);
+ const [destModalOpen, setDestModalOpen] = useState(false);
+
+ // 경고 다이얼로그 (출발/도착 변경 시)
+ const [pendingChange, setPendingChange] = useState(null);
+
+ // 확정 중
+ const [committing, setCommitting] = useState(false);
+
+ // 임시저장 debounce
+ const saveTimerRef = useRef | null>(null);
+ const skipNextSaveRef = useRef(false);
+
+ // 마운트: 창고 + 임시저장 복원
+ useEffect(() => {
+ (async () => {
+ try {
+ const whRes = await getReceivingWarehouses();
+ if (whRes.success) setWarehouses(whRes.data);
+ } catch {
+ /* ignore */
+ }
+ try {
+ const tempRes = await loadTempMove();
+ if (tempRes.success && tempRes.data.length > 0) {
+ const parsed: QueueRow[] = [];
+ for (const t of tempRes.data) {
+ try {
+ const row = JSON.parse(t.row_data) as QueueRow;
+ if (row && row.stock_id) parsed.push(row);
+ } catch {
+ /* ignore broken row */
+ }
+ }
+ if (parsed.length > 0) {
+ skipNextSaveRef.current = true;
+ setQueue(parsed);
+ const first = parsed[0];
+ if (first.dest_warehouse_code && first.dest_warehouse_name) {
+ setDestWarehouse({
+ warehouse_code: first.dest_warehouse_code,
+ warehouse_name: first.dest_warehouse_name,
+ } as WarehouseOption);
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ })();
+ }, []);
+
+ // 재고 목록 fetch
+ const fetchStocks = useCallback(async (warehouseCode: string, kw: string) => {
+ setStocksLoading(true);
+ try {
+ const res = await getMoveStockList({ warehouse_code: warehouseCode, keyword: kw || undefined });
+ if (res.success) setStocks(res.data);
+ } catch {
+ setStocks([]);
+ } finally {
+ setStocksLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (sourceTab !== "warehouse") return;
+ fetchStocks(selectedFromCode, appliedKeyword);
+ }, [sourceTab, selectedFromCode, appliedKeyword, fetchStocks]);
+
+ // 큐 변경 시 자동 임시저장 (debounce)
+ useEffect(() => {
+ if (skipNextSaveRef.current) {
+ skipNextSaveRef.current = false;
+ return;
+ }
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => {
+ saveTempMove(queue).catch(() => {});
+ }, TEMP_SAVE_DEBOUNCE_MS);
+ return () => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ };
+ }, [queue]);
+
+ // 큐에 추가
+ const addToQueue = useCallback((stock: MoveStockItem, qty: number) => {
+ const stockQty = parseFloat(stock.current_qty) || 0;
+ const finalQty = Math.min(Math.max(qty, 1), stockQty);
+ setQueue((prev) => {
+ const exists = prev.find((q) => q.stock_id === stock.id);
+ if (exists) {
+ return prev.map((q) =>
+ q.stock_id === stock.id ? { ...q, qty: finalQty } : q
+ );
+ }
+ return [
+ ...prev,
+ {
+ stock_id: stock.id,
+ item_code: stock.item_code,
+ item_name: stock.item_name,
+ item_number: stock.item_number,
+ from_warehouse_code: stock.warehouse_code,
+ from_warehouse_name: stock.warehouse_name,
+ from_location_code: stock.location_code || "",
+ qty: finalQty,
+ dest_warehouse_code: destWarehouse?.warehouse_code ?? null,
+ dest_warehouse_name: destWarehouse?.warehouse_name ?? null,
+ },
+ ];
+ });
+ }, [destWarehouse]);
+
+ const removeFromQueue = useCallback((stock_id: string) => {
+ setQueue((prev) => prev.filter((q) => q.stock_id !== stock_id));
+ }, []);
+
+ // 큐가 비어있지 않을 때 출발창고 변경 → 경고
+ const handleFromChipClick = (code: string) => {
+ if (code === selectedFromCode) return;
+ if (queue.length > 0) {
+ setPendingChange({ kind: "fromWarehouse", value: code });
+ return;
+ }
+ setSelectedFromCode(code);
+ };
+
+ // 도착창고 모달 선택
+ const handleDestSelect = (w: WarehouseOption) => {
+ if (destWarehouse && w.warehouse_code === destWarehouse.warehouse_code) {
+ setDestModalOpen(false);
+ return;
+ }
+ if (queue.length > 0 && destWarehouse) {
+ setPendingChange({ kind: "destWarehouse", value: w });
+ setDestModalOpen(false);
+ return;
+ }
+ setDestWarehouse(w);
+ setDestModalOpen(false);
+ };
+
+ // 다이얼로그 확인 → 큐 비우고 변경 적용
+ const confirmPendingChange = () => {
+ if (!pendingChange) return;
+ setQueue([]);
+ if (pendingChange.kind === "fromWarehouse") {
+ setSelectedFromCode(pendingChange.value as string);
+ } else if (pendingChange.kind === "destWarehouse") {
+ setDestWarehouse(pendingChange.value as WarehouseOption);
+ }
+ setPendingChange(null);
+ };
+
+ // 키패드
+ const openNumpad = (stock: MoveStockItem) => {
+ setNumpadTarget(stock);
+ setNumpadOpen(true);
+ };
+ const handleNumpadConfirm = (qty: number) => {
+ if (!numpadTarget) return;
+ addToQueue(numpadTarget, qty);
+ setNumpadTarget(null);
+ setNumpadOpen(false);
+ };
+
+ // 이동 확정
+ const handleCommit = async () => {
+ if (committing) return;
+ if (queue.length === 0) return;
+ if (!destWarehouse) return;
+ setCommitting(true);
+ try {
+ const res = await commitMove({
+ items: queue.map((q) => ({ stock_id: q.stock_id, qty: q.qty })),
+ to_warehouse_code: destWarehouse.warehouse_code,
+ });
+ if (res.success) {
+ const failed = (res.data?.results || []).filter((r) => r.status !== "ok");
+ if (failed.length > 0) {
+ const msg = failed
+ .map((f) => `${f.stock_id}: ${f.reason || f.status}`)
+ .join("\n");
+ alert(`일부 항목 실패\n\n${msg}`);
+ }
+ setQueue([]);
+ fetchStocks(selectedFromCode, appliedKeyword);
+ } else {
+ alert(res.message || "이동 확정 실패");
+ }
+ } catch (e: any) {
+ alert(`이동 확정 오류: ${e?.message || e}`);
+ } finally {
+ setCommitting(false);
+ }
+ };
+
+ // ===== Derived =====
+
+ const queueIds = useMemo(() => new Set(queue.map((q) => q.stock_id)), [queue]);
+ const queueCount = queue.length;
+
+ // 출발 칩 옵션: "전체" + 등록된 창고 전체
+ const fromChips: Array<{ code: string; name: string }> = useMemo(() => {
+ return [
+ { code: ALL_CODE, name: "전체" },
+ ...warehouses.map((w) => ({ code: w.warehouse_code, name: w.warehouse_name })),
+ ];
+ }, [warehouses]);
+
+ return (
+
+ {/* ===== Back + Title + History Button ===== */}
+
+
+
router.push(companyPath("/pop/inventory"))}
+ 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"
+ >
+
+
+
+
+
+
재고이동
+
출발 창고에서 도착 창고로 품목을 이동합니다
+
+
+
+
+
{ /* TODO: 다음 세션 - 이동내역 라우팅 */ }}
+ className="px-4 py-2 rounded-xl bg-white border border-gray-200 text-sm text-gray-600 font-medium flex items-center gap-1.5 hover:bg-gray-50 active:scale-95 transition-all"
+ >
+
+ 이동내역
+
+
+
+ {/* ===== 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 (
+ handleFromChipClick(c.code)}
+ className={
+ "shrink-0 px-4 py-2 rounded-xl text-sm font-semibold transition-all whitespace-nowrap " +
+ (active
+ ? "bg-orange-500 text-white shadow-sm"
+ : "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50")
+ }
+ >
+ {c.name}
+
+ );
+ })}
+
+
+ {/* 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"
+ />
+ setAppliedKeyword(keyword.trim())}
+ className="px-6 py-2 rounded-xl bg-orange-500 text-white text-sm font-bold hover:bg-orange-600 active:scale-95 transition-all"
+ >
+ 검색
+
+
+
+
+ {/* 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 ===== */}
+
+
+
+
이동 대기
+
setDestModalOpen(true)}
+ className="px-3 py-2.5 rounded-lg bg-white border border-gray-200 text-xs font-semibold text-gray-700 hover:bg-gray-50 active:scale-95 transition-all flex items-center gap-1"
+ >
+
+ {destWarehouse ? destWarehouse.warehouse_name : "도착창고"}
+
+
+
+ {queueCount}건
+
+
+
+
+ {queue.length === 0 ? (
+
+ 선택된 항목이 없습니다
+
+ ) : (
+ queue.map((r) => (
+
removeFromQueue(r.stock_id)}
+ />
+ ))
+ )}
+
+
+
+ {committing ? "처리 중..." : `이동 확정 (${queueCount}건)`}
+
+
+
+
+ {/* ===== 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 (
+
+ {label}
+
+ );
+}
+
+// ===== 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}}
+
+
+
+
+ {displayQty.toLocaleString()}
+
+ {stock.unit || "EA"}
+
+
+
+
+
+
+ );
+}
+
+// ===== 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 (
+
onSelect(w)}
+ className={
+ "w-full px-4 py-3 rounded-xl border text-left transition-all active:scale-[0.99] " +
+ (active
+ ? "bg-orange-50 border-orange-400 text-orange-700"
+ : "bg-white border-gray-200 text-gray-700 hover:bg-gray-50")
+ }
+ >
+ {w.warehouse_name}
+ {w.warehouse_code}{w.warehouse_type ? ` · ${w.warehouse_type}` : ""}
+
+ );
+ })
+ )}
+
+
+
+ );
+}
+
+// ===== 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({
정지
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
@@ -1823,13 +1847,15 @@ export function ProcessWork({
재개
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
diff --git a/frontend/app/(main)/COMPANY_30/pop/inventory/inout-manage/page.tsx b/frontend/app/(main)/COMPANY_30/pop/inventory/inout-manage/page.tsx
index e4dd0337..24786994 100644
--- a/frontend/app/(main)/COMPANY_30/pop/inventory/inout-manage/page.tsx
+++ b/frontend/app/(main)/COMPANY_30/pop/inventory/inout-manage/page.tsx
@@ -125,7 +125,7 @@ function extractReason(record: InOutRow): string {
// type 은 세분화된 값(구매입고/판매출고 등)이라 direction 기반 분기 사용
function signedQty(record: InOutRow, qty: number): number {
if (qty === 0) return 0;
- if (record.type === "조정") {
+ if (record.type === "조정" || record.type === "이동") {
const sq = Number(record.signed_qty);
if (Number.isFinite(sq)) return sq;
return qty;
@@ -690,7 +690,12 @@ function RecordCard({
{warehouse}
{adjust ? (
diff --git a/frontend/app/(main)/COMPANY_30/pop/inventory/move/page.tsx b/frontend/app/(main)/COMPANY_30/pop/inventory/move/page.tsx
new file mode 100644
index 00000000..f3796d5a
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_30/pop/inventory/move/page.tsx
@@ -0,0 +1,739 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
+import { SimpleKeypadModal } from "../../_components/common/SimpleKeypadModal";
+import { getReceivingWarehouses, type WarehouseOption } from "@/lib/api/receiving";
+import {
+ getMoveStockList,
+ commitMove,
+ loadTempMove,
+ saveTempMove,
+ type MoveStockItem,
+} from "@/lib/api/popInventoryMove";
+
+// ===== Types =====
+
+interface QueueRow {
+ stock_id: string;
+ item_code: string;
+ item_name: string;
+ item_number: string;
+ from_warehouse_code: string;
+ from_warehouse_name: string;
+ from_location_code: string;
+ qty: number;
+ // 도착창고 마커 (임시저장 복원용, 모든 큐 row 가 동일 값)
+ dest_warehouse_code: string | null;
+ dest_warehouse_name: string | null;
+}
+
+type SourceTab = "warehouse" | "process";
+
+interface PendingChange {
+ kind: "fromWarehouse" | "destWarehouse";
+ value: string | WarehouseOption | null;
+}
+
+const TEMP_SAVE_DEBOUNCE_MS = 400;
+const ALL_CODE = "all";
+
+// ===== Page =====
+
+export default function InventoryMovePage() {
+ const router = useRouter();
+ const companyPath = usePopCompanyPath();
+
+ // 좌측
+ const [sourceTab, setSourceTab] = useState("warehouse");
+ const [warehouses, setWarehouses] = useState([]);
+ const [selectedFromCode, setSelectedFromCode] = useState(ALL_CODE);
+ const [keyword, setKeyword] = useState("");
+ const [appliedKeyword, setAppliedKeyword] = useState("");
+ const [stocks, setStocks] = useState([]);
+ const [stocksLoading, setStocksLoading] = useState(false);
+
+ // 키패드
+ const [numpadOpen, setNumpadOpen] = useState(false);
+ const [numpadTarget, setNumpadTarget] = useState(null);
+
+ // 우측 큐
+ const [queue, setQueue] = useState([]);
+ const [destWarehouse, setDestWarehouse] = useState(null);
+ const [destModalOpen, setDestModalOpen] = useState(false);
+
+ // 경고 다이얼로그 (출발/도착 변경 시)
+ const [pendingChange, setPendingChange] = useState(null);
+
+ // 확정 중
+ const [committing, setCommitting] = useState(false);
+
+ // 임시저장 debounce
+ const saveTimerRef = useRef | null>(null);
+ const skipNextSaveRef = useRef(false);
+
+ // 마운트: 창고 + 임시저장 복원
+ useEffect(() => {
+ (async () => {
+ try {
+ const whRes = await getReceivingWarehouses();
+ if (whRes.success) setWarehouses(whRes.data);
+ } catch {
+ /* ignore */
+ }
+ try {
+ const tempRes = await loadTempMove();
+ if (tempRes.success && tempRes.data.length > 0) {
+ const parsed: QueueRow[] = [];
+ for (const t of tempRes.data) {
+ try {
+ const row = JSON.parse(t.row_data) as QueueRow;
+ if (row && row.stock_id) parsed.push(row);
+ } catch {
+ /* ignore broken row */
+ }
+ }
+ if (parsed.length > 0) {
+ skipNextSaveRef.current = true;
+ setQueue(parsed);
+ const first = parsed[0];
+ if (first.dest_warehouse_code && first.dest_warehouse_name) {
+ setDestWarehouse({
+ warehouse_code: first.dest_warehouse_code,
+ warehouse_name: first.dest_warehouse_name,
+ } as WarehouseOption);
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ })();
+ }, []);
+
+ // 재고 목록 fetch
+ const fetchStocks = useCallback(async (warehouseCode: string, kw: string) => {
+ setStocksLoading(true);
+ try {
+ const res = await getMoveStockList({ warehouse_code: warehouseCode, keyword: kw || undefined });
+ if (res.success) setStocks(res.data);
+ } catch {
+ setStocks([]);
+ } finally {
+ setStocksLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (sourceTab !== "warehouse") return;
+ fetchStocks(selectedFromCode, appliedKeyword);
+ }, [sourceTab, selectedFromCode, appliedKeyword, fetchStocks]);
+
+ // 큐 변경 시 자동 임시저장 (debounce)
+ useEffect(() => {
+ if (skipNextSaveRef.current) {
+ skipNextSaveRef.current = false;
+ return;
+ }
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => {
+ saveTempMove(queue).catch(() => {});
+ }, TEMP_SAVE_DEBOUNCE_MS);
+ return () => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ };
+ }, [queue]);
+
+ // 큐에 추가
+ const addToQueue = useCallback((stock: MoveStockItem, qty: number) => {
+ const stockQty = parseFloat(stock.current_qty) || 0;
+ const finalQty = Math.min(Math.max(qty, 1), stockQty);
+ setQueue((prev) => {
+ const exists = prev.find((q) => q.stock_id === stock.id);
+ if (exists) {
+ return prev.map((q) =>
+ q.stock_id === stock.id ? { ...q, qty: finalQty } : q
+ );
+ }
+ return [
+ ...prev,
+ {
+ stock_id: stock.id,
+ item_code: stock.item_code,
+ item_name: stock.item_name,
+ item_number: stock.item_number,
+ from_warehouse_code: stock.warehouse_code,
+ from_warehouse_name: stock.warehouse_name,
+ from_location_code: stock.location_code || "",
+ qty: finalQty,
+ dest_warehouse_code: destWarehouse?.warehouse_code ?? null,
+ dest_warehouse_name: destWarehouse?.warehouse_name ?? null,
+ },
+ ];
+ });
+ }, [destWarehouse]);
+
+ const removeFromQueue = useCallback((stock_id: string) => {
+ setQueue((prev) => prev.filter((q) => q.stock_id !== stock_id));
+ }, []);
+
+ // 큐가 비어있지 않을 때 출발창고 변경 → 경고
+ const handleFromChipClick = (code: string) => {
+ if (code === selectedFromCode) return;
+ if (queue.length > 0) {
+ setPendingChange({ kind: "fromWarehouse", value: code });
+ return;
+ }
+ setSelectedFromCode(code);
+ };
+
+ // 도착창고 모달 선택
+ const handleDestSelect = (w: WarehouseOption) => {
+ if (destWarehouse && w.warehouse_code === destWarehouse.warehouse_code) {
+ setDestModalOpen(false);
+ return;
+ }
+ if (queue.length > 0 && destWarehouse) {
+ setPendingChange({ kind: "destWarehouse", value: w });
+ setDestModalOpen(false);
+ return;
+ }
+ setDestWarehouse(w);
+ setDestModalOpen(false);
+ };
+
+ // 다이얼로그 확인 → 큐 비우고 변경 적용
+ const confirmPendingChange = () => {
+ if (!pendingChange) return;
+ setQueue([]);
+ if (pendingChange.kind === "fromWarehouse") {
+ setSelectedFromCode(pendingChange.value as string);
+ } else if (pendingChange.kind === "destWarehouse") {
+ setDestWarehouse(pendingChange.value as WarehouseOption);
+ }
+ setPendingChange(null);
+ };
+
+ // 키패드
+ const openNumpad = (stock: MoveStockItem) => {
+ setNumpadTarget(stock);
+ setNumpadOpen(true);
+ };
+ const handleNumpadConfirm = (qty: number) => {
+ if (!numpadTarget) return;
+ addToQueue(numpadTarget, qty);
+ setNumpadTarget(null);
+ setNumpadOpen(false);
+ };
+
+ // 이동 확정
+ const handleCommit = async () => {
+ if (committing) return;
+ if (queue.length === 0) return;
+ if (!destWarehouse) return;
+ setCommitting(true);
+ try {
+ const res = await commitMove({
+ items: queue.map((q) => ({ stock_id: q.stock_id, qty: q.qty })),
+ to_warehouse_code: destWarehouse.warehouse_code,
+ });
+ if (res.success) {
+ const failed = (res.data?.results || []).filter((r) => r.status !== "ok");
+ if (failed.length > 0) {
+ const msg = failed
+ .map((f) => `${f.stock_id}: ${f.reason || f.status}`)
+ .join("\n");
+ alert(`일부 항목 실패\n\n${msg}`);
+ }
+ setQueue([]);
+ fetchStocks(selectedFromCode, appliedKeyword);
+ } else {
+ alert(res.message || "이동 확정 실패");
+ }
+ } catch (e: any) {
+ alert(`이동 확정 오류: ${e?.message || e}`);
+ } finally {
+ setCommitting(false);
+ }
+ };
+
+ // ===== Derived =====
+
+ const queueIds = useMemo(() => new Set(queue.map((q) => q.stock_id)), [queue]);
+ const queueCount = queue.length;
+
+ // 출발 칩 옵션: "전체" + 등록된 창고 전체
+ const fromChips: Array<{ code: string; name: string }> = useMemo(() => {
+ return [
+ { code: ALL_CODE, name: "전체" },
+ ...warehouses.map((w) => ({ code: w.warehouse_code, name: w.warehouse_name })),
+ ];
+ }, [warehouses]);
+
+ return (
+
+ {/* ===== Back + Title + History Button ===== */}
+
+
+
router.push(companyPath("/pop/inventory"))}
+ 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"
+ >
+
+
+
+
+
+
재고이동
+
출발 창고에서 도착 창고로 품목을 이동합니다
+
+
+
+
+
{ /* TODO: 다음 세션 - 이동내역 라우팅 */ }}
+ className="px-4 py-2 rounded-xl bg-white border border-gray-200 text-sm text-gray-600 font-medium flex items-center gap-1.5 hover:bg-gray-50 active:scale-95 transition-all"
+ >
+
+ 이동내역
+
+
+
+ {/* ===== 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 (
+ handleFromChipClick(c.code)}
+ className={
+ "shrink-0 px-4 py-2 rounded-xl text-sm font-semibold transition-all whitespace-nowrap " +
+ (active
+ ? "bg-orange-500 text-white shadow-sm"
+ : "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50")
+ }
+ >
+ {c.name}
+
+ );
+ })}
+
+
+ {/* 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"
+ />
+ setAppliedKeyword(keyword.trim())}
+ className="px-6 py-2 rounded-xl bg-orange-500 text-white text-sm font-bold hover:bg-orange-600 active:scale-95 transition-all"
+ >
+ 검색
+
+
+
+
+ {/* 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 ===== */}
+
+
+
+
이동 대기
+
setDestModalOpen(true)}
+ className="px-3 py-2.5 rounded-lg bg-white border border-gray-200 text-xs font-semibold text-gray-700 hover:bg-gray-50 active:scale-95 transition-all flex items-center gap-1"
+ >
+
+ {destWarehouse ? destWarehouse.warehouse_name : "도착창고"}
+
+
+
+ {queueCount}건
+
+
+
+
+ {queue.length === 0 ? (
+
+ 선택된 항목이 없습니다
+
+ ) : (
+ queue.map((r) => (
+
removeFromQueue(r.stock_id)}
+ />
+ ))
+ )}
+
+
+
+ {committing ? "처리 중..." : `이동 확정 (${queueCount}건)`}
+
+
+
+
+ {/* ===== 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 (
+
+ {label}
+
+ );
+}
+
+// ===== 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}}
+
+
+
+
+ {displayQty.toLocaleString()}
+
+ {stock.unit || "EA"}
+
+
+
+
+
+
+ );
+}
+
+// ===== 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 (
+
onSelect(w)}
+ className={
+ "w-full px-4 py-3 rounded-xl border text-left transition-all active:scale-[0.99] " +
+ (active
+ ? "bg-orange-50 border-orange-400 text-orange-700"
+ : "bg-white border-gray-200 text-gray-700 hover:bg-gray-50")
+ }
+ >
+ {w.warehouse_name}
+ {w.warehouse_code}{w.warehouse_type ? ` · ${w.warehouse_type}` : ""}
+
+ );
+ })
+ )}
+
+
+
+ );
+}
+
+// ===== 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({
정지
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
@@ -1823,13 +1847,15 @@ export function ProcessWork({
재개
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
diff --git a/frontend/app/(main)/COMPANY_7/pop/inventory/inout-manage/page.tsx b/frontend/app/(main)/COMPANY_7/pop/inventory/inout-manage/page.tsx
index e4dd0337..24786994 100644
--- a/frontend/app/(main)/COMPANY_7/pop/inventory/inout-manage/page.tsx
+++ b/frontend/app/(main)/COMPANY_7/pop/inventory/inout-manage/page.tsx
@@ -125,7 +125,7 @@ function extractReason(record: InOutRow): string {
// type 은 세분화된 값(구매입고/판매출고 등)이라 direction 기반 분기 사용
function signedQty(record: InOutRow, qty: number): number {
if (qty === 0) return 0;
- if (record.type === "조정") {
+ if (record.type === "조정" || record.type === "이동") {
const sq = Number(record.signed_qty);
if (Number.isFinite(sq)) return sq;
return qty;
@@ -690,7 +690,12 @@ function RecordCard({
{warehouse}
{adjust ? (
diff --git a/frontend/app/(main)/COMPANY_7/pop/inventory/move/page.tsx b/frontend/app/(main)/COMPANY_7/pop/inventory/move/page.tsx
new file mode 100644
index 00000000..f3796d5a
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_7/pop/inventory/move/page.tsx
@@ -0,0 +1,739 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
+import { SimpleKeypadModal } from "../../_components/common/SimpleKeypadModal";
+import { getReceivingWarehouses, type WarehouseOption } from "@/lib/api/receiving";
+import {
+ getMoveStockList,
+ commitMove,
+ loadTempMove,
+ saveTempMove,
+ type MoveStockItem,
+} from "@/lib/api/popInventoryMove";
+
+// ===== Types =====
+
+interface QueueRow {
+ stock_id: string;
+ item_code: string;
+ item_name: string;
+ item_number: string;
+ from_warehouse_code: string;
+ from_warehouse_name: string;
+ from_location_code: string;
+ qty: number;
+ // 도착창고 마커 (임시저장 복원용, 모든 큐 row 가 동일 값)
+ dest_warehouse_code: string | null;
+ dest_warehouse_name: string | null;
+}
+
+type SourceTab = "warehouse" | "process";
+
+interface PendingChange {
+ kind: "fromWarehouse" | "destWarehouse";
+ value: string | WarehouseOption | null;
+}
+
+const TEMP_SAVE_DEBOUNCE_MS = 400;
+const ALL_CODE = "all";
+
+// ===== Page =====
+
+export default function InventoryMovePage() {
+ const router = useRouter();
+ const companyPath = usePopCompanyPath();
+
+ // 좌측
+ const [sourceTab, setSourceTab] = useState("warehouse");
+ const [warehouses, setWarehouses] = useState([]);
+ const [selectedFromCode, setSelectedFromCode] = useState(ALL_CODE);
+ const [keyword, setKeyword] = useState("");
+ const [appliedKeyword, setAppliedKeyword] = useState("");
+ const [stocks, setStocks] = useState([]);
+ const [stocksLoading, setStocksLoading] = useState(false);
+
+ // 키패드
+ const [numpadOpen, setNumpadOpen] = useState(false);
+ const [numpadTarget, setNumpadTarget] = useState(null);
+
+ // 우측 큐
+ const [queue, setQueue] = useState([]);
+ const [destWarehouse, setDestWarehouse] = useState(null);
+ const [destModalOpen, setDestModalOpen] = useState(false);
+
+ // 경고 다이얼로그 (출발/도착 변경 시)
+ const [pendingChange, setPendingChange] = useState(null);
+
+ // 확정 중
+ const [committing, setCommitting] = useState(false);
+
+ // 임시저장 debounce
+ const saveTimerRef = useRef | null>(null);
+ const skipNextSaveRef = useRef(false);
+
+ // 마운트: 창고 + 임시저장 복원
+ useEffect(() => {
+ (async () => {
+ try {
+ const whRes = await getReceivingWarehouses();
+ if (whRes.success) setWarehouses(whRes.data);
+ } catch {
+ /* ignore */
+ }
+ try {
+ const tempRes = await loadTempMove();
+ if (tempRes.success && tempRes.data.length > 0) {
+ const parsed: QueueRow[] = [];
+ for (const t of tempRes.data) {
+ try {
+ const row = JSON.parse(t.row_data) as QueueRow;
+ if (row && row.stock_id) parsed.push(row);
+ } catch {
+ /* ignore broken row */
+ }
+ }
+ if (parsed.length > 0) {
+ skipNextSaveRef.current = true;
+ setQueue(parsed);
+ const first = parsed[0];
+ if (first.dest_warehouse_code && first.dest_warehouse_name) {
+ setDestWarehouse({
+ warehouse_code: first.dest_warehouse_code,
+ warehouse_name: first.dest_warehouse_name,
+ } as WarehouseOption);
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ })();
+ }, []);
+
+ // 재고 목록 fetch
+ const fetchStocks = useCallback(async (warehouseCode: string, kw: string) => {
+ setStocksLoading(true);
+ try {
+ const res = await getMoveStockList({ warehouse_code: warehouseCode, keyword: kw || undefined });
+ if (res.success) setStocks(res.data);
+ } catch {
+ setStocks([]);
+ } finally {
+ setStocksLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (sourceTab !== "warehouse") return;
+ fetchStocks(selectedFromCode, appliedKeyword);
+ }, [sourceTab, selectedFromCode, appliedKeyword, fetchStocks]);
+
+ // 큐 변경 시 자동 임시저장 (debounce)
+ useEffect(() => {
+ if (skipNextSaveRef.current) {
+ skipNextSaveRef.current = false;
+ return;
+ }
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => {
+ saveTempMove(queue).catch(() => {});
+ }, TEMP_SAVE_DEBOUNCE_MS);
+ return () => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ };
+ }, [queue]);
+
+ // 큐에 추가
+ const addToQueue = useCallback((stock: MoveStockItem, qty: number) => {
+ const stockQty = parseFloat(stock.current_qty) || 0;
+ const finalQty = Math.min(Math.max(qty, 1), stockQty);
+ setQueue((prev) => {
+ const exists = prev.find((q) => q.stock_id === stock.id);
+ if (exists) {
+ return prev.map((q) =>
+ q.stock_id === stock.id ? { ...q, qty: finalQty } : q
+ );
+ }
+ return [
+ ...prev,
+ {
+ stock_id: stock.id,
+ item_code: stock.item_code,
+ item_name: stock.item_name,
+ item_number: stock.item_number,
+ from_warehouse_code: stock.warehouse_code,
+ from_warehouse_name: stock.warehouse_name,
+ from_location_code: stock.location_code || "",
+ qty: finalQty,
+ dest_warehouse_code: destWarehouse?.warehouse_code ?? null,
+ dest_warehouse_name: destWarehouse?.warehouse_name ?? null,
+ },
+ ];
+ });
+ }, [destWarehouse]);
+
+ const removeFromQueue = useCallback((stock_id: string) => {
+ setQueue((prev) => prev.filter((q) => q.stock_id !== stock_id));
+ }, []);
+
+ // 큐가 비어있지 않을 때 출발창고 변경 → 경고
+ const handleFromChipClick = (code: string) => {
+ if (code === selectedFromCode) return;
+ if (queue.length > 0) {
+ setPendingChange({ kind: "fromWarehouse", value: code });
+ return;
+ }
+ setSelectedFromCode(code);
+ };
+
+ // 도착창고 모달 선택
+ const handleDestSelect = (w: WarehouseOption) => {
+ if (destWarehouse && w.warehouse_code === destWarehouse.warehouse_code) {
+ setDestModalOpen(false);
+ return;
+ }
+ if (queue.length > 0 && destWarehouse) {
+ setPendingChange({ kind: "destWarehouse", value: w });
+ setDestModalOpen(false);
+ return;
+ }
+ setDestWarehouse(w);
+ setDestModalOpen(false);
+ };
+
+ // 다이얼로그 확인 → 큐 비우고 변경 적용
+ const confirmPendingChange = () => {
+ if (!pendingChange) return;
+ setQueue([]);
+ if (pendingChange.kind === "fromWarehouse") {
+ setSelectedFromCode(pendingChange.value as string);
+ } else if (pendingChange.kind === "destWarehouse") {
+ setDestWarehouse(pendingChange.value as WarehouseOption);
+ }
+ setPendingChange(null);
+ };
+
+ // 키패드
+ const openNumpad = (stock: MoveStockItem) => {
+ setNumpadTarget(stock);
+ setNumpadOpen(true);
+ };
+ const handleNumpadConfirm = (qty: number) => {
+ if (!numpadTarget) return;
+ addToQueue(numpadTarget, qty);
+ setNumpadTarget(null);
+ setNumpadOpen(false);
+ };
+
+ // 이동 확정
+ const handleCommit = async () => {
+ if (committing) return;
+ if (queue.length === 0) return;
+ if (!destWarehouse) return;
+ setCommitting(true);
+ try {
+ const res = await commitMove({
+ items: queue.map((q) => ({ stock_id: q.stock_id, qty: q.qty })),
+ to_warehouse_code: destWarehouse.warehouse_code,
+ });
+ if (res.success) {
+ const failed = (res.data?.results || []).filter((r) => r.status !== "ok");
+ if (failed.length > 0) {
+ const msg = failed
+ .map((f) => `${f.stock_id}: ${f.reason || f.status}`)
+ .join("\n");
+ alert(`일부 항목 실패\n\n${msg}`);
+ }
+ setQueue([]);
+ fetchStocks(selectedFromCode, appliedKeyword);
+ } else {
+ alert(res.message || "이동 확정 실패");
+ }
+ } catch (e: any) {
+ alert(`이동 확정 오류: ${e?.message || e}`);
+ } finally {
+ setCommitting(false);
+ }
+ };
+
+ // ===== Derived =====
+
+ const queueIds = useMemo(() => new Set(queue.map((q) => q.stock_id)), [queue]);
+ const queueCount = queue.length;
+
+ // 출발 칩 옵션: "전체" + 등록된 창고 전체
+ const fromChips: Array<{ code: string; name: string }> = useMemo(() => {
+ return [
+ { code: ALL_CODE, name: "전체" },
+ ...warehouses.map((w) => ({ code: w.warehouse_code, name: w.warehouse_name })),
+ ];
+ }, [warehouses]);
+
+ return (
+
+ {/* ===== Back + Title + History Button ===== */}
+
+
+
router.push(companyPath("/pop/inventory"))}
+ 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"
+ >
+
+
+
+
+
+
재고이동
+
출발 창고에서 도착 창고로 품목을 이동합니다
+
+
+
+
+
{ /* TODO: 다음 세션 - 이동내역 라우팅 */ }}
+ className="px-4 py-2 rounded-xl bg-white border border-gray-200 text-sm text-gray-600 font-medium flex items-center gap-1.5 hover:bg-gray-50 active:scale-95 transition-all"
+ >
+
+ 이동내역
+
+
+
+ {/* ===== 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 (
+ handleFromChipClick(c.code)}
+ className={
+ "shrink-0 px-4 py-2 rounded-xl text-sm font-semibold transition-all whitespace-nowrap " +
+ (active
+ ? "bg-orange-500 text-white shadow-sm"
+ : "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50")
+ }
+ >
+ {c.name}
+
+ );
+ })}
+
+
+ {/* 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"
+ />
+ setAppliedKeyword(keyword.trim())}
+ className="px-6 py-2 rounded-xl bg-orange-500 text-white text-sm font-bold hover:bg-orange-600 active:scale-95 transition-all"
+ >
+ 검색
+
+
+
+
+ {/* 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 ===== */}
+
+
+
+
이동 대기
+
setDestModalOpen(true)}
+ className="px-3 py-2.5 rounded-lg bg-white border border-gray-200 text-xs font-semibold text-gray-700 hover:bg-gray-50 active:scale-95 transition-all flex items-center gap-1"
+ >
+
+ {destWarehouse ? destWarehouse.warehouse_name : "도착창고"}
+
+
+
+ {queueCount}건
+
+
+
+
+ {queue.length === 0 ? (
+
+ 선택된 항목이 없습니다
+
+ ) : (
+ queue.map((r) => (
+
removeFromQueue(r.stock_id)}
+ />
+ ))
+ )}
+
+
+
+ {committing ? "처리 중..." : `이동 확정 (${queueCount}건)`}
+
+
+
+
+ {/* ===== 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 (
+
+ {label}
+
+ );
+}
+
+// ===== 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}}
+
+
+
+
+ {displayQty.toLocaleString()}
+
+ {stock.unit || "EA"}
+
+
+
+
+
+
+ );
+}
+
+// ===== 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 (
+
onSelect(w)}
+ className={
+ "w-full px-4 py-3 rounded-xl border text-left transition-all active:scale-[0.99] " +
+ (active
+ ? "bg-orange-50 border-orange-400 text-orange-700"
+ : "bg-white border-gray-200 text-gray-700 hover:bg-gray-50")
+ }
+ >
+ {w.warehouse_name}
+ {w.warehouse_code}{w.warehouse_type ? ` · ${w.warehouse_type}` : ""}
+
+ );
+ })
+ )}
+
+
+
+ );
+}
+
+// ===== 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({
정지
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
@@ -1823,13 +1847,15 @@ export function ProcessWork({
재개
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
diff --git a/frontend/app/(main)/COMPANY_8/pop/inventory/inout-manage/page.tsx b/frontend/app/(main)/COMPANY_8/pop/inventory/inout-manage/page.tsx
index e4dd0337..24786994 100644
--- a/frontend/app/(main)/COMPANY_8/pop/inventory/inout-manage/page.tsx
+++ b/frontend/app/(main)/COMPANY_8/pop/inventory/inout-manage/page.tsx
@@ -125,7 +125,7 @@ function extractReason(record: InOutRow): string {
// type 은 세분화된 값(구매입고/판매출고 등)이라 direction 기반 분기 사용
function signedQty(record: InOutRow, qty: number): number {
if (qty === 0) return 0;
- if (record.type === "조정") {
+ if (record.type === "조정" || record.type === "이동") {
const sq = Number(record.signed_qty);
if (Number.isFinite(sq)) return sq;
return qty;
@@ -690,7 +690,12 @@ function RecordCard({
{warehouse}
{adjust ? (
diff --git a/frontend/app/(main)/COMPANY_8/pop/inventory/move/page.tsx b/frontend/app/(main)/COMPANY_8/pop/inventory/move/page.tsx
new file mode 100644
index 00000000..f3796d5a
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_8/pop/inventory/move/page.tsx
@@ -0,0 +1,739 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
+import { SimpleKeypadModal } from "../../_components/common/SimpleKeypadModal";
+import { getReceivingWarehouses, type WarehouseOption } from "@/lib/api/receiving";
+import {
+ getMoveStockList,
+ commitMove,
+ loadTempMove,
+ saveTempMove,
+ type MoveStockItem,
+} from "@/lib/api/popInventoryMove";
+
+// ===== Types =====
+
+interface QueueRow {
+ stock_id: string;
+ item_code: string;
+ item_name: string;
+ item_number: string;
+ from_warehouse_code: string;
+ from_warehouse_name: string;
+ from_location_code: string;
+ qty: number;
+ // 도착창고 마커 (임시저장 복원용, 모든 큐 row 가 동일 값)
+ dest_warehouse_code: string | null;
+ dest_warehouse_name: string | null;
+}
+
+type SourceTab = "warehouse" | "process";
+
+interface PendingChange {
+ kind: "fromWarehouse" | "destWarehouse";
+ value: string | WarehouseOption | null;
+}
+
+const TEMP_SAVE_DEBOUNCE_MS = 400;
+const ALL_CODE = "all";
+
+// ===== Page =====
+
+export default function InventoryMovePage() {
+ const router = useRouter();
+ const companyPath = usePopCompanyPath();
+
+ // 좌측
+ const [sourceTab, setSourceTab] = useState("warehouse");
+ const [warehouses, setWarehouses] = useState([]);
+ const [selectedFromCode, setSelectedFromCode] = useState(ALL_CODE);
+ const [keyword, setKeyword] = useState("");
+ const [appliedKeyword, setAppliedKeyword] = useState("");
+ const [stocks, setStocks] = useState([]);
+ const [stocksLoading, setStocksLoading] = useState(false);
+
+ // 키패드
+ const [numpadOpen, setNumpadOpen] = useState(false);
+ const [numpadTarget, setNumpadTarget] = useState(null);
+
+ // 우측 큐
+ const [queue, setQueue] = useState([]);
+ const [destWarehouse, setDestWarehouse] = useState(null);
+ const [destModalOpen, setDestModalOpen] = useState(false);
+
+ // 경고 다이얼로그 (출발/도착 변경 시)
+ const [pendingChange, setPendingChange] = useState(null);
+
+ // 확정 중
+ const [committing, setCommitting] = useState(false);
+
+ // 임시저장 debounce
+ const saveTimerRef = useRef | null>(null);
+ const skipNextSaveRef = useRef(false);
+
+ // 마운트: 창고 + 임시저장 복원
+ useEffect(() => {
+ (async () => {
+ try {
+ const whRes = await getReceivingWarehouses();
+ if (whRes.success) setWarehouses(whRes.data);
+ } catch {
+ /* ignore */
+ }
+ try {
+ const tempRes = await loadTempMove();
+ if (tempRes.success && tempRes.data.length > 0) {
+ const parsed: QueueRow[] = [];
+ for (const t of tempRes.data) {
+ try {
+ const row = JSON.parse(t.row_data) as QueueRow;
+ if (row && row.stock_id) parsed.push(row);
+ } catch {
+ /* ignore broken row */
+ }
+ }
+ if (parsed.length > 0) {
+ skipNextSaveRef.current = true;
+ setQueue(parsed);
+ const first = parsed[0];
+ if (first.dest_warehouse_code && first.dest_warehouse_name) {
+ setDestWarehouse({
+ warehouse_code: first.dest_warehouse_code,
+ warehouse_name: first.dest_warehouse_name,
+ } as WarehouseOption);
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ })();
+ }, []);
+
+ // 재고 목록 fetch
+ const fetchStocks = useCallback(async (warehouseCode: string, kw: string) => {
+ setStocksLoading(true);
+ try {
+ const res = await getMoveStockList({ warehouse_code: warehouseCode, keyword: kw || undefined });
+ if (res.success) setStocks(res.data);
+ } catch {
+ setStocks([]);
+ } finally {
+ setStocksLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (sourceTab !== "warehouse") return;
+ fetchStocks(selectedFromCode, appliedKeyword);
+ }, [sourceTab, selectedFromCode, appliedKeyword, fetchStocks]);
+
+ // 큐 변경 시 자동 임시저장 (debounce)
+ useEffect(() => {
+ if (skipNextSaveRef.current) {
+ skipNextSaveRef.current = false;
+ return;
+ }
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => {
+ saveTempMove(queue).catch(() => {});
+ }, TEMP_SAVE_DEBOUNCE_MS);
+ return () => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ };
+ }, [queue]);
+
+ // 큐에 추가
+ const addToQueue = useCallback((stock: MoveStockItem, qty: number) => {
+ const stockQty = parseFloat(stock.current_qty) || 0;
+ const finalQty = Math.min(Math.max(qty, 1), stockQty);
+ setQueue((prev) => {
+ const exists = prev.find((q) => q.stock_id === stock.id);
+ if (exists) {
+ return prev.map((q) =>
+ q.stock_id === stock.id ? { ...q, qty: finalQty } : q
+ );
+ }
+ return [
+ ...prev,
+ {
+ stock_id: stock.id,
+ item_code: stock.item_code,
+ item_name: stock.item_name,
+ item_number: stock.item_number,
+ from_warehouse_code: stock.warehouse_code,
+ from_warehouse_name: stock.warehouse_name,
+ from_location_code: stock.location_code || "",
+ qty: finalQty,
+ dest_warehouse_code: destWarehouse?.warehouse_code ?? null,
+ dest_warehouse_name: destWarehouse?.warehouse_name ?? null,
+ },
+ ];
+ });
+ }, [destWarehouse]);
+
+ const removeFromQueue = useCallback((stock_id: string) => {
+ setQueue((prev) => prev.filter((q) => q.stock_id !== stock_id));
+ }, []);
+
+ // 큐가 비어있지 않을 때 출발창고 변경 → 경고
+ const handleFromChipClick = (code: string) => {
+ if (code === selectedFromCode) return;
+ if (queue.length > 0) {
+ setPendingChange({ kind: "fromWarehouse", value: code });
+ return;
+ }
+ setSelectedFromCode(code);
+ };
+
+ // 도착창고 모달 선택
+ const handleDestSelect = (w: WarehouseOption) => {
+ if (destWarehouse && w.warehouse_code === destWarehouse.warehouse_code) {
+ setDestModalOpen(false);
+ return;
+ }
+ if (queue.length > 0 && destWarehouse) {
+ setPendingChange({ kind: "destWarehouse", value: w });
+ setDestModalOpen(false);
+ return;
+ }
+ setDestWarehouse(w);
+ setDestModalOpen(false);
+ };
+
+ // 다이얼로그 확인 → 큐 비우고 변경 적용
+ const confirmPendingChange = () => {
+ if (!pendingChange) return;
+ setQueue([]);
+ if (pendingChange.kind === "fromWarehouse") {
+ setSelectedFromCode(pendingChange.value as string);
+ } else if (pendingChange.kind === "destWarehouse") {
+ setDestWarehouse(pendingChange.value as WarehouseOption);
+ }
+ setPendingChange(null);
+ };
+
+ // 키패드
+ const openNumpad = (stock: MoveStockItem) => {
+ setNumpadTarget(stock);
+ setNumpadOpen(true);
+ };
+ const handleNumpadConfirm = (qty: number) => {
+ if (!numpadTarget) return;
+ addToQueue(numpadTarget, qty);
+ setNumpadTarget(null);
+ setNumpadOpen(false);
+ };
+
+ // 이동 확정
+ const handleCommit = async () => {
+ if (committing) return;
+ if (queue.length === 0) return;
+ if (!destWarehouse) return;
+ setCommitting(true);
+ try {
+ const res = await commitMove({
+ items: queue.map((q) => ({ stock_id: q.stock_id, qty: q.qty })),
+ to_warehouse_code: destWarehouse.warehouse_code,
+ });
+ if (res.success) {
+ const failed = (res.data?.results || []).filter((r) => r.status !== "ok");
+ if (failed.length > 0) {
+ const msg = failed
+ .map((f) => `${f.stock_id}: ${f.reason || f.status}`)
+ .join("\n");
+ alert(`일부 항목 실패\n\n${msg}`);
+ }
+ setQueue([]);
+ fetchStocks(selectedFromCode, appliedKeyword);
+ } else {
+ alert(res.message || "이동 확정 실패");
+ }
+ } catch (e: any) {
+ alert(`이동 확정 오류: ${e?.message || e}`);
+ } finally {
+ setCommitting(false);
+ }
+ };
+
+ // ===== Derived =====
+
+ const queueIds = useMemo(() => new Set(queue.map((q) => q.stock_id)), [queue]);
+ const queueCount = queue.length;
+
+ // 출발 칩 옵션: "전체" + 등록된 창고 전체
+ const fromChips: Array<{ code: string; name: string }> = useMemo(() => {
+ return [
+ { code: ALL_CODE, name: "전체" },
+ ...warehouses.map((w) => ({ code: w.warehouse_code, name: w.warehouse_name })),
+ ];
+ }, [warehouses]);
+
+ return (
+
+ {/* ===== Back + Title + History Button ===== */}
+
+
+
router.push(companyPath("/pop/inventory"))}
+ 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"
+ >
+
+
+
+
+
+
재고이동
+
출발 창고에서 도착 창고로 품목을 이동합니다
+
+
+
+
+
{ /* TODO: 다음 세션 - 이동내역 라우팅 */ }}
+ className="px-4 py-2 rounded-xl bg-white border border-gray-200 text-sm text-gray-600 font-medium flex items-center gap-1.5 hover:bg-gray-50 active:scale-95 transition-all"
+ >
+
+ 이동내역
+
+
+
+ {/* ===== 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 (
+ handleFromChipClick(c.code)}
+ className={
+ "shrink-0 px-4 py-2 rounded-xl text-sm font-semibold transition-all whitespace-nowrap " +
+ (active
+ ? "bg-orange-500 text-white shadow-sm"
+ : "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50")
+ }
+ >
+ {c.name}
+
+ );
+ })}
+
+
+ {/* 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"
+ />
+ setAppliedKeyword(keyword.trim())}
+ className="px-6 py-2 rounded-xl bg-orange-500 text-white text-sm font-bold hover:bg-orange-600 active:scale-95 transition-all"
+ >
+ 검색
+
+
+
+
+ {/* 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 ===== */}
+
+
+
+
이동 대기
+
setDestModalOpen(true)}
+ className="px-3 py-2.5 rounded-lg bg-white border border-gray-200 text-xs font-semibold text-gray-700 hover:bg-gray-50 active:scale-95 transition-all flex items-center gap-1"
+ >
+
+ {destWarehouse ? destWarehouse.warehouse_name : "도착창고"}
+
+
+
+ {queueCount}건
+
+
+
+
+ {queue.length === 0 ? (
+
+ 선택된 항목이 없습니다
+
+ ) : (
+ queue.map((r) => (
+
removeFromQueue(r.stock_id)}
+ />
+ ))
+ )}
+
+
+
+ {committing ? "처리 중..." : `이동 확정 (${queueCount}건)`}
+
+
+
+
+ {/* ===== 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 (
+
+ {label}
+
+ );
+}
+
+// ===== 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}}
+
+
+
+
+ {displayQty.toLocaleString()}
+
+ {stock.unit || "EA"}
+
+
+
+
+
+
+ );
+}
+
+// ===== 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 (
+
onSelect(w)}
+ className={
+ "w-full px-4 py-3 rounded-xl border text-left transition-all active:scale-[0.99] " +
+ (active
+ ? "bg-orange-50 border-orange-400 text-orange-700"
+ : "bg-white border-gray-200 text-gray-700 hover:bg-gray-50")
+ }
+ >
+ {w.warehouse_name}
+ {w.warehouse_code}{w.warehouse_type ? ` · ${w.warehouse_type}` : ""}
+
+ );
+ })
+ )}
+
+
+
+ );
+}
+
+// ===== 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({
정지
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
@@ -1823,13 +1847,15 @@ export function ProcessWork({
재개
handlePhaseTimerAction("complete", ph)
}
- className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all"
+ className="h-9 px-4 rounded-lg text-sm font-bold text-white active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
style={{
- background:
- "linear-gradient(135deg,#10b981,#059669)",
+ background: requiredMetByPhase[ph]
+ ? "linear-gradient(135deg,#10b981,#059669)"
+ : "linear-gradient(135deg,#9ca3af,#6b7280)",
}}
>
종료
diff --git a/frontend/app/(main)/COMPANY_9/pop/inventory/inout-manage/page.tsx b/frontend/app/(main)/COMPANY_9/pop/inventory/inout-manage/page.tsx
index e4dd0337..24786994 100644
--- a/frontend/app/(main)/COMPANY_9/pop/inventory/inout-manage/page.tsx
+++ b/frontend/app/(main)/COMPANY_9/pop/inventory/inout-manage/page.tsx
@@ -125,7 +125,7 @@ function extractReason(record: InOutRow): string {
// type 은 세분화된 값(구매입고/판매출고 등)이라 direction 기반 분기 사용
function signedQty(record: InOutRow, qty: number): number {
if (qty === 0) return 0;
- if (record.type === "조정") {
+ if (record.type === "조정" || record.type === "이동") {
const sq = Number(record.signed_qty);
if (Number.isFinite(sq)) return sq;
return qty;
@@ -690,7 +690,12 @@ function RecordCard({
{warehouse}
{adjust ? (
diff --git a/frontend/app/(main)/COMPANY_9/pop/inventory/move/page.tsx b/frontend/app/(main)/COMPANY_9/pop/inventory/move/page.tsx
new file mode 100644
index 00000000..f3796d5a
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_9/pop/inventory/move/page.tsx
@@ -0,0 +1,739 @@
+"use client";
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
+import { SimpleKeypadModal } from "../../_components/common/SimpleKeypadModal";
+import { getReceivingWarehouses, type WarehouseOption } from "@/lib/api/receiving";
+import {
+ getMoveStockList,
+ commitMove,
+ loadTempMove,
+ saveTempMove,
+ type MoveStockItem,
+} from "@/lib/api/popInventoryMove";
+
+// ===== Types =====
+
+interface QueueRow {
+ stock_id: string;
+ item_code: string;
+ item_name: string;
+ item_number: string;
+ from_warehouse_code: string;
+ from_warehouse_name: string;
+ from_location_code: string;
+ qty: number;
+ // 도착창고 마커 (임시저장 복원용, 모든 큐 row 가 동일 값)
+ dest_warehouse_code: string | null;
+ dest_warehouse_name: string | null;
+}
+
+type SourceTab = "warehouse" | "process";
+
+interface PendingChange {
+ kind: "fromWarehouse" | "destWarehouse";
+ value: string | WarehouseOption | null;
+}
+
+const TEMP_SAVE_DEBOUNCE_MS = 400;
+const ALL_CODE = "all";
+
+// ===== Page =====
+
+export default function InventoryMovePage() {
+ const router = useRouter();
+ const companyPath = usePopCompanyPath();
+
+ // 좌측
+ const [sourceTab, setSourceTab] = useState("warehouse");
+ const [warehouses, setWarehouses] = useState([]);
+ const [selectedFromCode, setSelectedFromCode] = useState(ALL_CODE);
+ const [keyword, setKeyword] = useState("");
+ const [appliedKeyword, setAppliedKeyword] = useState("");
+ const [stocks, setStocks] = useState([]);
+ const [stocksLoading, setStocksLoading] = useState(false);
+
+ // 키패드
+ const [numpadOpen, setNumpadOpen] = useState(false);
+ const [numpadTarget, setNumpadTarget] = useState(null);
+
+ // 우측 큐
+ const [queue, setQueue] = useState([]);
+ const [destWarehouse, setDestWarehouse] = useState(null);
+ const [destModalOpen, setDestModalOpen] = useState(false);
+
+ // 경고 다이얼로그 (출발/도착 변경 시)
+ const [pendingChange, setPendingChange] = useState(null);
+
+ // 확정 중
+ const [committing, setCommitting] = useState(false);
+
+ // 임시저장 debounce
+ const saveTimerRef = useRef | null>(null);
+ const skipNextSaveRef = useRef(false);
+
+ // 마운트: 창고 + 임시저장 복원
+ useEffect(() => {
+ (async () => {
+ try {
+ const whRes = await getReceivingWarehouses();
+ if (whRes.success) setWarehouses(whRes.data);
+ } catch {
+ /* ignore */
+ }
+ try {
+ const tempRes = await loadTempMove();
+ if (tempRes.success && tempRes.data.length > 0) {
+ const parsed: QueueRow[] = [];
+ for (const t of tempRes.data) {
+ try {
+ const row = JSON.parse(t.row_data) as QueueRow;
+ if (row && row.stock_id) parsed.push(row);
+ } catch {
+ /* ignore broken row */
+ }
+ }
+ if (parsed.length > 0) {
+ skipNextSaveRef.current = true;
+ setQueue(parsed);
+ const first = parsed[0];
+ if (first.dest_warehouse_code && first.dest_warehouse_name) {
+ setDestWarehouse({
+ warehouse_code: first.dest_warehouse_code,
+ warehouse_name: first.dest_warehouse_name,
+ } as WarehouseOption);
+ }
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+ })();
+ }, []);
+
+ // 재고 목록 fetch
+ const fetchStocks = useCallback(async (warehouseCode: string, kw: string) => {
+ setStocksLoading(true);
+ try {
+ const res = await getMoveStockList({ warehouse_code: warehouseCode, keyword: kw || undefined });
+ if (res.success) setStocks(res.data);
+ } catch {
+ setStocks([]);
+ } finally {
+ setStocksLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (sourceTab !== "warehouse") return;
+ fetchStocks(selectedFromCode, appliedKeyword);
+ }, [sourceTab, selectedFromCode, appliedKeyword, fetchStocks]);
+
+ // 큐 변경 시 자동 임시저장 (debounce)
+ useEffect(() => {
+ if (skipNextSaveRef.current) {
+ skipNextSaveRef.current = false;
+ return;
+ }
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(() => {
+ saveTempMove(queue).catch(() => {});
+ }, TEMP_SAVE_DEBOUNCE_MS);
+ return () => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ };
+ }, [queue]);
+
+ // 큐에 추가
+ const addToQueue = useCallback((stock: MoveStockItem, qty: number) => {
+ const stockQty = parseFloat(stock.current_qty) || 0;
+ const finalQty = Math.min(Math.max(qty, 1), stockQty);
+ setQueue((prev) => {
+ const exists = prev.find((q) => q.stock_id === stock.id);
+ if (exists) {
+ return prev.map((q) =>
+ q.stock_id === stock.id ? { ...q, qty: finalQty } : q
+ );
+ }
+ return [
+ ...prev,
+ {
+ stock_id: stock.id,
+ item_code: stock.item_code,
+ item_name: stock.item_name,
+ item_number: stock.item_number,
+ from_warehouse_code: stock.warehouse_code,
+ from_warehouse_name: stock.warehouse_name,
+ from_location_code: stock.location_code || "",
+ qty: finalQty,
+ dest_warehouse_code: destWarehouse?.warehouse_code ?? null,
+ dest_warehouse_name: destWarehouse?.warehouse_name ?? null,
+ },
+ ];
+ });
+ }, [destWarehouse]);
+
+ const removeFromQueue = useCallback((stock_id: string) => {
+ setQueue((prev) => prev.filter((q) => q.stock_id !== stock_id));
+ }, []);
+
+ // 큐가 비어있지 않을 때 출발창고 변경 → 경고
+ const handleFromChipClick = (code: string) => {
+ if (code === selectedFromCode) return;
+ if (queue.length > 0) {
+ setPendingChange({ kind: "fromWarehouse", value: code });
+ return;
+ }
+ setSelectedFromCode(code);
+ };
+
+ // 도착창고 모달 선택
+ const handleDestSelect = (w: WarehouseOption) => {
+ if (destWarehouse && w.warehouse_code === destWarehouse.warehouse_code) {
+ setDestModalOpen(false);
+ return;
+ }
+ if (queue.length > 0 && destWarehouse) {
+ setPendingChange({ kind: "destWarehouse", value: w });
+ setDestModalOpen(false);
+ return;
+ }
+ setDestWarehouse(w);
+ setDestModalOpen(false);
+ };
+
+ // 다이얼로그 확인 → 큐 비우고 변경 적용
+ const confirmPendingChange = () => {
+ if (!pendingChange) return;
+ setQueue([]);
+ if (pendingChange.kind === "fromWarehouse") {
+ setSelectedFromCode(pendingChange.value as string);
+ } else if (pendingChange.kind === "destWarehouse") {
+ setDestWarehouse(pendingChange.value as WarehouseOption);
+ }
+ setPendingChange(null);
+ };
+
+ // 키패드
+ const openNumpad = (stock: MoveStockItem) => {
+ setNumpadTarget(stock);
+ setNumpadOpen(true);
+ };
+ const handleNumpadConfirm = (qty: number) => {
+ if (!numpadTarget) return;
+ addToQueue(numpadTarget, qty);
+ setNumpadTarget(null);
+ setNumpadOpen(false);
+ };
+
+ // 이동 확정
+ const handleCommit = async () => {
+ if (committing) return;
+ if (queue.length === 0) return;
+ if (!destWarehouse) return;
+ setCommitting(true);
+ try {
+ const res = await commitMove({
+ items: queue.map((q) => ({ stock_id: q.stock_id, qty: q.qty })),
+ to_warehouse_code: destWarehouse.warehouse_code,
+ });
+ if (res.success) {
+ const failed = (res.data?.results || []).filter((r) => r.status !== "ok");
+ if (failed.length > 0) {
+ const msg = failed
+ .map((f) => `${f.stock_id}: ${f.reason || f.status}`)
+ .join("\n");
+ alert(`일부 항목 실패\n\n${msg}`);
+ }
+ setQueue([]);
+ fetchStocks(selectedFromCode, appliedKeyword);
+ } else {
+ alert(res.message || "이동 확정 실패");
+ }
+ } catch (e: any) {
+ alert(`이동 확정 오류: ${e?.message || e}`);
+ } finally {
+ setCommitting(false);
+ }
+ };
+
+ // ===== Derived =====
+
+ const queueIds = useMemo(() => new Set(queue.map((q) => q.stock_id)), [queue]);
+ const queueCount = queue.length;
+
+ // 출발 칩 옵션: "전체" + 등록된 창고 전체
+ const fromChips: Array<{ code: string; name: string }> = useMemo(() => {
+ return [
+ { code: ALL_CODE, name: "전체" },
+ ...warehouses.map((w) => ({ code: w.warehouse_code, name: w.warehouse_name })),
+ ];
+ }, [warehouses]);
+
+ return (
+
+ {/* ===== Back + Title + History Button ===== */}
+
+
+
router.push(companyPath("/pop/inventory"))}
+ 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"
+ >
+
+
+
+
+
+
재고이동
+
출발 창고에서 도착 창고로 품목을 이동합니다
+
+
+
+
+
{ /* TODO: 다음 세션 - 이동내역 라우팅 */ }}
+ className="px-4 py-2 rounded-xl bg-white border border-gray-200 text-sm text-gray-600 font-medium flex items-center gap-1.5 hover:bg-gray-50 active:scale-95 transition-all"
+ >
+
+ 이동내역
+
+
+
+ {/* ===== 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 (
+ handleFromChipClick(c.code)}
+ className={
+ "shrink-0 px-4 py-2 rounded-xl text-sm font-semibold transition-all whitespace-nowrap " +
+ (active
+ ? "bg-orange-500 text-white shadow-sm"
+ : "bg-white border border-gray-200 text-gray-600 hover:bg-gray-50")
+ }
+ >
+ {c.name}
+
+ );
+ })}
+
+
+ {/* 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"
+ />
+ setAppliedKeyword(keyword.trim())}
+ className="px-6 py-2 rounded-xl bg-orange-500 text-white text-sm font-bold hover:bg-orange-600 active:scale-95 transition-all"
+ >
+ 검색
+
+
+
+
+ {/* 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 ===== */}
+
+
+
+
이동 대기
+
setDestModalOpen(true)}
+ className="px-3 py-2.5 rounded-lg bg-white border border-gray-200 text-xs font-semibold text-gray-700 hover:bg-gray-50 active:scale-95 transition-all flex items-center gap-1"
+ >
+
+ {destWarehouse ? destWarehouse.warehouse_name : "도착창고"}
+
+
+
+ {queueCount}건
+
+
+
+
+ {queue.length === 0 ? (
+
+ 선택된 항목이 없습니다
+
+ ) : (
+ queue.map((r) => (
+
removeFromQueue(r.stock_id)}
+ />
+ ))
+ )}
+
+
+
+ {committing ? "처리 중..." : `이동 확정 (${queueCount}건)`}
+
+
+
+
+ {/* ===== 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 (
+
+ {label}
+
+ );
+}
+
+// ===== 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}}
+
+
+
+
+ {displayQty.toLocaleString()}
+
+ {stock.unit || "EA"}
+
+
+
+
+
+
+ );
+}
+
+// ===== 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 (
+
onSelect(w)}
+ className={
+ "w-full px-4 py-3 rounded-xl border text-left transition-all active:scale-[0.99] " +
+ (active
+ ? "bg-orange-50 border-orange-400 text-orange-700"
+ : "bg-white border-gray-200 text-gray-700 hover:bg-gray-50")
+ }
+ >
+ {w.warehouse_name}
+ {w.warehouse_code}{w.warehouse_type ? ` · ${w.warehouse_type}` : ""}
+
+ );
+ })
+ )}
+
+
+
+ );
+}
+
+// ===== 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 };
+}