From cdea504d9079d95b4dcc1a46e206846f8ecdc0fa Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 9 Apr 2026 18:09:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20POP=20sync=20detail=20fallback=20+?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sync: header에 routing 없으면 detail에서 자동 가져오기 (PC→POP 연동 수정) - sync: header routing/qty/item_id 자동 보정 (detail → header 동기화) - 사이드바: 체크리스트/자재투입 카드 형태 UI로 변경 --- .../controllers/popProductionController.ts | 28 ++++++++++++- .../pop/hardcoded/production/ProcessWork.tsx | 42 ++++++++++++------- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 92cba89d..dabb2f1f 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -359,11 +359,21 @@ export const syncWorkInstructions = async ( }); // 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목 + // header에 routing 없으면 detail에서 가져옴 (PC가 detail에만 저장하는 경우 대응) const unsyncedResult = await pool.query( - `SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty + `SELECT wi.id, wi.work_instruction_no, + COALESCE(wi.routing, wid.routing_version_id) AS routing, + COALESCE(NULLIF(wi.qty, ''), wid.qty) AS qty, + COALESCE(wi.item_id, (SELECT id FROM item_info WHERE item_number = wid.item_number AND company_code = $1 LIMIT 1)) AS item_id FROM work_instruction wi + LEFT JOIN LATERAL ( + SELECT routing_version_id, qty, item_number + FROM work_instruction_detail + WHERE work_instruction_no = wi.work_instruction_no AND company_code = $1 + LIMIT 1 + ) wid ON true WHERE wi.company_code = $1 - AND wi.routing IS NOT NULL + AND COALESCE(wi.routing, wid.routing_version_id) IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM work_order_process wop WHERE wop.wo_id = wi.id AND wop.company_code = $1 @@ -373,6 +383,20 @@ export const syncWorkInstructions = async ( const unsynced = unsyncedResult.rows; + // header에 routing/qty/item_id가 비어있으면 자동 보정 (detail → header 동기화) + for (const wi of unsynced) { + await pool.query( + `UPDATE work_instruction SET + routing = COALESCE(routing, $2), + qty = COALESCE(NULLIF(qty, ''), $3), + item_id = COALESCE(item_id, $4), + updated_date = NOW() + WHERE id = $1 AND company_code = $5 + AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`, + [wi.id, wi.routing, wi.qty, wi.item_id, companyCode], + ); + } + if (unsynced.length === 0) { return res.json({ success: true, diff --git a/frontend/components/pop/hardcoded/production/ProcessWork.tsx b/frontend/components/pop/hardcoded/production/ProcessWork.tsx index 1675aad3..6f603e43 100644 --- a/frontend/components/pop/hardcoded/production/ProcessWork.tsx +++ b/frontend/components/pop/hardcoded/production/ProcessWork.tsx @@ -1208,34 +1208,45 @@ export function ProcessWork({ processId }: ProcessWorkProps) { behavior: "smooth", }); }} - className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${ + className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${ isSelected - ? "border-l-[3px] border-l-gray-900 bg-white" - : "border-l-[3px] border-l-transparent hover:bg-gray-50" + ? "bg-blue-50 border-2 border-blue-400 shadow-sm" + : isDone + ? "bg-green-50/50 border border-green-200" + : "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm" }`} + style={{ width: "calc(100% - 16px)" }} > {g.title} {g.completed}/{g.total} @@ -1259,15 +1270,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) { behavior: "smooth", }); }} - className={`w-full flex items-center gap-2 px-3 py-2.5 mb-2 text-left transition-all ${ + className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${ activeSection === "material" - ? "border-l-[3px] border-l-gray-900 bg-white" - : "border-l-[3px] border-l-transparent hover:bg-gray-50" + ? "bg-blue-50 border-2 border-blue-400 shadow-sm" + : "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm" }`} + style={{ width: "calc(100% - 16px)" }} > - 📦 + 📦 자재 투입 From 9f009881105473fadcec888c2a6f5e439717cc56 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 10 Apr 2026 10:28:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20POP=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=E2=80=94=20=EC=8B=A0=EA=B7=9C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=205=EA=B0=9C=20+=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=209=EA=B1=B4=20+=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [신규 화면] - 설비허브 + 설비관리 + 설비점검 - 재고조정 + 재고이동 [버그 수정] - 창고 NULL status 누락 - 작업지시 sync detail fallback - InspectionModal API 경로 - 검사결과 DB 저장 - seq_no 비순차 대응 - 출고 재고 부족 검증 - 자동 창고 매칭 - 내 접수 목록 필터 [UI 개선] - 사이드바 카드형 - 자재투입 컴팩트 - 커스텀 모달 - 불필요 버튼 제거 --- .../src/controllers/receivingController.ts | 2 +- .../(pop)/pop/equipment/inspection/page.tsx | 6 + .../(pop)/pop/equipment/management/page.tsx | 6 + frontend/app/(pop)/pop/equipment/page.tsx | 6 + .../app/(pop)/pop/inventory/move/page.tsx | 6 + .../app/(pop)/pop/inventory/transfer/page.tsx | 6 + .../components/pop/hardcoded/MenuIcons.tsx | 2 +- .../pop/hardcoded/equipment/EquipmentHome.tsx | 198 ++++++++++++ .../equipment/EquipmentInspection.tsx | 119 ++++++++ .../pop/hardcoded/equipment/EquipmentList.tsx | 169 ++++++++++ .../pop/hardcoded/inventory/InventoryHome.tsx | 24 +- .../pop/hardcoded/inventory/InventoryMove.tsx | 289 ++++++++++++++++++ .../hardcoded/inventory/InventoryTransfer.tsx | 252 +++++++++++++++ .../pop/hardcoded/production/ProcessWork.tsx | 183 ++++------- .../hardcoded/production/WorkOrderList.tsx | 2 +- 15 files changed, 1135 insertions(+), 135 deletions(-) create mode 100644 frontend/app/(pop)/pop/equipment/inspection/page.tsx create mode 100644 frontend/app/(pop)/pop/equipment/management/page.tsx create mode 100644 frontend/app/(pop)/pop/equipment/page.tsx create mode 100644 frontend/app/(pop)/pop/inventory/move/page.tsx create mode 100644 frontend/app/(pop)/pop/inventory/transfer/page.tsx create mode 100644 frontend/components/pop/hardcoded/equipment/EquipmentHome.tsx create mode 100644 frontend/components/pop/hardcoded/equipment/EquipmentInspection.tsx create mode 100644 frontend/components/pop/hardcoded/equipment/EquipmentList.tsx create mode 100644 frontend/components/pop/hardcoded/inventory/InventoryMove.tsx create mode 100644 frontend/components/pop/hardcoded/inventory/InventoryTransfer.tsx diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index ef732ace..c57cd7c0 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -1050,7 +1050,7 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) { const result = await pool.query( `SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info - WHERE company_code = $1 AND status != '삭제' + WHERE company_code = $1 AND COALESCE(status, '') != '삭제' ORDER BY warehouse_name`, [companyCode], ); diff --git a/frontend/app/(pop)/pop/equipment/inspection/page.tsx b/frontend/app/(pop)/pop/equipment/inspection/page.tsx new file mode 100644 index 00000000..3c7a5444 --- /dev/null +++ b/frontend/app/(pop)/pop/equipment/inspection/page.tsx @@ -0,0 +1,6 @@ +"use client"; +import { EquipmentInspection } from "@/components/pop/hardcoded/equipment/EquipmentInspection"; + +export default function EquipmentInspectionPage() { + return ; +} diff --git a/frontend/app/(pop)/pop/equipment/management/page.tsx b/frontend/app/(pop)/pop/equipment/management/page.tsx new file mode 100644 index 00000000..90abb5ef --- /dev/null +++ b/frontend/app/(pop)/pop/equipment/management/page.tsx @@ -0,0 +1,6 @@ +"use client"; +import { EquipmentList } from "@/components/pop/hardcoded/equipment/EquipmentList"; + +export default function EquipmentManagementPage() { + return ; +} diff --git a/frontend/app/(pop)/pop/equipment/page.tsx b/frontend/app/(pop)/pop/equipment/page.tsx new file mode 100644 index 00000000..421afbc8 --- /dev/null +++ b/frontend/app/(pop)/pop/equipment/page.tsx @@ -0,0 +1,6 @@ +"use client"; +import { EquipmentHome } from "@/components/pop/hardcoded/equipment/EquipmentHome"; + +export default function EquipmentPage() { + return ; +} diff --git a/frontend/app/(pop)/pop/inventory/move/page.tsx b/frontend/app/(pop)/pop/inventory/move/page.tsx new file mode 100644 index 00000000..d47b06ef --- /dev/null +++ b/frontend/app/(pop)/pop/inventory/move/page.tsx @@ -0,0 +1,6 @@ +"use client"; +import { InventoryMove } from "@/components/pop/hardcoded/inventory/InventoryMove"; + +export default function InventoryMovePage() { + return ; +} diff --git a/frontend/app/(pop)/pop/inventory/transfer/page.tsx b/frontend/app/(pop)/pop/inventory/transfer/page.tsx new file mode 100644 index 00000000..c58b92db --- /dev/null +++ b/frontend/app/(pop)/pop/inventory/transfer/page.tsx @@ -0,0 +1,6 @@ +"use client"; +import { InventoryTransfer } from "@/components/pop/hardcoded/inventory/InventoryTransfer"; + +export default function TransferPage() { + return ; +} diff --git a/frontend/components/pop/hardcoded/MenuIcons.tsx b/frontend/components/pop/hardcoded/MenuIcons.tsx index 4d2f93e2..66ddc7a5 100644 --- a/frontend/components/pop/hardcoded/MenuIcons.tsx +++ b/frontend/components/pop/hardcoded/MenuIcons.tsx @@ -126,7 +126,7 @@ const MENU_ITEMS: MenuIconItem[] = [ /> ), - href: "/pop/screens/equipment", + href: "/pop/equipment", }, { id: "inventory", diff --git a/frontend/components/pop/hardcoded/equipment/EquipmentHome.tsx b/frontend/components/pop/hardcoded/equipment/EquipmentHome.tsx new file mode 100644 index 00000000..bfba61fe --- /dev/null +++ b/frontend/components/pop/hardcoded/equipment/EquipmentHome.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import React, { useEffect, useState } from "react"; +import { apiClient } from "@/lib/api/client"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface EquipmentItem { + id: string; + equipment_code: string; + equipment_name: string; + equipment_type?: string; + status?: string; +} + +interface KpiData { + total: number; + active: number; + idle: number; + inspect: number; + rate: string; +} + +/* ------------------------------------------------------------------ */ +/* Menu */ +/* ------------------------------------------------------------------ */ + +const MENU_ITEMS = [ + { + id: "management", + title: "설비관리", + gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)", + shadowColor: "rgba(139,92,246,.3)", + icon: ( + + + + ), + href: "/pop/equipment/management", + }, + { + id: "inspection", + title: "설비점검", + gradient: "linear-gradient(135deg,#f59e0b,#d97706)", + shadowColor: "rgba(245,158,11,.3)", + icon: ( + + + + ), + href: "/pop/equipment/inspection", + }, +]; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function EquipmentHome() { + const router = useRouter(); + + const [kpi, setKpi] = useState({ + total: 0, + active: 0, + idle: 0, + inspect: 0, + rate: "0%", + }); + const [recentItems, setRecentItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const res = await apiClient.get("/data/equipment_mng", { + params: { pageSize: 500 }, + }); + const data = res.data?.data?.data ?? res.data?.data ?? []; + const items = Array.isArray(data) ? data : []; + const total = items.length; + const active = items.filter((i: EquipmentItem) => !i.status || i.status === "가동" || i.status === "정상").length; + const idle = items.filter((i: EquipmentItem) => i.status === "대기").length; + const inspect = items.filter((i: EquipmentItem) => i.status === "점검").length; + const rate = total > 0 ? `${Math.round((active / total) * 100)}%` : "0%"; + setKpi({ total, active, idle, inspect, rate }); + setRecentItems(items.slice(0, 5)); + } catch { /* */ } + setLoading(false); + }; + fetchData(); + }, []); + + return ( +
+ {/* Header */} +
+
+ +
+

설비

+

설비 현황 및 점검 관리

+
+
+ + {/* KPI */} +
+
+ {[ + { value: loading ? "-" : kpi.total, label: "전체", color: "text-gray-900" }, + { value: loading ? "-" : kpi.active, label: "가동", color: "text-green-600" }, + { value: loading ? "-" : kpi.idle, label: "대기", color: "text-blue-600" }, + { value: loading ? "-" : kpi.inspect, label: "점검", color: "text-red-600" }, + { value: loading ? "-" : kpi.rate, label: "가동률", color: "text-purple-600" }, + ].map((item) => ( +
+

{item.value}

+

{item.label}

+
+ ))} +
+
+
+ + {/* Menu Icons */} +
+

+ + 설비 관리 +

+
+ {MENU_ITEMS.map((item) => ( + + ))} +
+
+ + {/* Recent Equipment */} +
+
+
+

최근 설비

+ 최근 5건 +
+ {loading ? ( +
+
+
+ ) : recentItems.length === 0 ? ( +
등록된 설비가 없습니다
+ ) : ( +
+ {recentItems.map((item) => ( +
+
+

{item.equipment_name || "-"}

+

{item.equipment_code} {item.equipment_type ? `· ${item.equipment_type}` : ""}

+
+ + {item.status || "정상"} + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/components/pop/hardcoded/equipment/EquipmentInspection.tsx b/frontend/components/pop/hardcoded/equipment/EquipmentInspection.tsx new file mode 100644 index 00000000..a78374b4 --- /dev/null +++ b/frontend/components/pop/hardcoded/equipment/EquipmentInspection.tsx @@ -0,0 +1,119 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { PopShell } from "../PopShell"; + +type TabType = "all" | "running" | "idle" | "inspect" | "stopped"; + +export function EquipmentInspection() { + const router = useRouter(); + const [activeTab, setActiveTab] = useState("all"); + const [searchKeyword, setSearchKeyword] = useState(""); + + // 시안 기준 KPI (점검 테이블 없으므로 0) + const kpi = { total: 0, running: 0, idle: 0, inspect: 0, rate: "0%" }; + + const tabs: { key: TabType; label: string; count: number }[] = [ + { key: "all", label: "전체", count: kpi.total }, + { key: "running", label: "가동", count: kpi.running }, + { key: "idle", label: "대기", count: kpi.idle }, + { key: "inspect", label: "점검", count: kpi.inspect }, + { key: "stopped", label: "비가동", count: 0 }, + ]; + + return ( + +
+ {/* Header */} +
+
+ +
+

🔧 점검관리

+
+
+ + {/* Search */} +
+ setSearchKeyword(e.target.value)} + className="flex-1 px-4 py-3 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-amber-400" + /> + + +
+
+ + {/* KPI */} +
+
+ {[ + { label: "전체", value: kpi.total, color: "border-t-amber-500", icon: "🎬" }, + { label: "가동", value: kpi.running, color: "border-t-green-500", icon: "🟢" }, + { label: "대기", value: kpi.idle, color: "border-t-blue-500", icon: "🔵" }, + { label: "점검", value: kpi.inspect, color: "border-t-red-500", icon: "🔴" }, + { label: "가동률", value: kpi.rate, color: "border-t-purple-500", icon: "📊" }, + ].map((item) => ( +
+

{item.icon}

+

{item.value}

+

{item.label}

+
+ ))} +
+
+ + {/* Tabs */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Card List — 데이터 없음 */} +
+
+ 🔧 +

등록된 설비 점검 정보가 없습니다

+

PC에서 설비 점검 데이터를 등록하면 여기에 표시됩니다

+
+
+
+
+ ); +} diff --git a/frontend/components/pop/hardcoded/equipment/EquipmentList.tsx b/frontend/components/pop/hardcoded/equipment/EquipmentList.tsx new file mode 100644 index 00000000..9de9b20b --- /dev/null +++ b/frontend/components/pop/hardcoded/equipment/EquipmentList.tsx @@ -0,0 +1,169 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; +import { PopShell } from "../PopShell"; + +interface EquipmentItem { + id: string; + equipment_code: string; + equipment_name: string; + equipment_type?: string; + location?: string; + status?: string; + last_inspection_date?: string; + next_inspection_date?: string; + memo?: string; +} + +export function EquipmentList() { + const router = useRouter(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [searchKeyword, setSearchKeyword] = useState(""); + + const fetchEquipments = useCallback(async () => { + setLoading(true); + try { + const res = await apiClient.get("/data/equipment_mng", { + params: { pageSize: 500 }, + }); + const data = res.data?.data?.data ?? res.data?.data ?? []; + setItems(Array.isArray(data) ? data : []); + } catch { + setItems([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchEquipments(); + }, [fetchEquipments]); + + const filtered = items.filter((item) => { + if (!searchKeyword) return true; + const kw = searchKeyword.toLowerCase(); + return ( + (item.equipment_code || "").toLowerCase().includes(kw) || + (item.equipment_name || "").toLowerCase().includes(kw) || + (item.equipment_type || "").toLowerCase().includes(kw) + ); + }); + + // KPI + const totalCount = items.length; + const activeCount = items.filter((i) => i.status === "가동" || i.status === "정상" || !i.status).length; + const stopCount = items.filter((i) => i.status === "정지" || i.status === "비가동").length; + + return ( + +
+ {/* Header */} +
+
+ +
+

설비관리

+

설비 현황을 조회합니다

+
+
+ + {/* Search */} +
+ + + + setSearchKeyword(e.target.value)} + className="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-blue-400" + /> +
+
+ + {/* KPI */} +
+
+
+

전체

+

{totalCount}

+
+
+

가동

+

{activeCount}

+
+
+

비가동

+

{stopCount}

+
+
+
+ + {/* List */} +
+
+

설비 목록

+ {filtered.length}건 +
+ + {loading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +
+ + + +

등록된 설비가 없습니다

+
+ ) : ( +
+ {filtered.map((item) => ( +
+
+
+

{item.equipment_name || "-"}

+

{item.equipment_code}

+
+ + {item.status || "정상"} + +
+
+ {item.equipment_type && ( +
유형: {item.equipment_type}
+ )} + {item.location && ( +
위치: {item.location}
+ )} +
+
+ ))} +
+ )} +
+
+ + ); +} diff --git a/frontend/components/pop/hardcoded/inventory/InventoryHome.tsx b/frontend/components/pop/hardcoded/inventory/InventoryHome.tsx index bf61e374..aa6d0ac9 100644 --- a/frontend/components/pop/hardcoded/inventory/InventoryHome.tsx +++ b/frontend/components/pop/hardcoded/inventory/InventoryHome.tsx @@ -95,7 +95,29 @@ const MENU_ITEMS = [ /> ), - href: "#", + href: "/pop/inventory/transfer", + }, + { + id: "move", + title: "재고이동", + gradient: "linear-gradient(135deg,#10b981,#059669)", + shadowColor: "rgba(16,185,129,.3)", + icon: ( + + + + ), + href: "/pop/inventory/move", }, ]; diff --git a/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx b/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx new file mode 100644 index 00000000..247076d9 --- /dev/null +++ b/frontend/components/pop/hardcoded/inventory/InventoryMove.tsx @@ -0,0 +1,289 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; +import { PopShell } from "../PopShell"; + +interface Warehouse { + id: string; + warehouse_code: string; + warehouse_name: string; +} + +interface StockItem { + id: string; + item_code: string; + item_name?: string; + warehouse_code: string; + location_code?: string; + current_qty: string; +} + +interface PendingItem { + stock: StockItem; + moveQty: number; + toWarehouse: string; +} + +export function InventoryMove() { + const router = useRouter(); + const [warehouses, setWarehouses] = useState([]); + const [fromWarehouse, setFromWarehouse] = useState(""); + const [toWarehouse, setToWarehouse] = useState(""); + const [stockItems, setStockItems] = useState([]); + const [loading, setLoading] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(""); + const [pendingItems, setPendingItems] = useState([]); + + const fetchWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/outbound/warehouses"); + setWarehouses(res.data?.data || []); + } catch { /* */ } + }, []); + + const fetchStock = useCallback(async () => { + if (!fromWarehouse) { setStockItems([]); return; } + setLoading(true); + try { + const res = await apiClient.get("/data/inventory_stock", { + params: { pageSize: "500", filters: JSON.stringify({ warehouse_code: fromWarehouse }) }, + }); + const data = res.data?.data?.data ?? res.data?.data ?? []; + setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []); + } catch { + setStockItems([]); + } finally { + setLoading(false); + } + }, [fromWarehouse]); + + useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]); + useEffect(() => { fetchStock(); }, [fetchStock]); + + const filtered = stockItems.filter((item) => { + if (!searchKeyword) return true; + const kw = searchKeyword.toLowerCase(); + return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw); + }); + + const addToPending = (stock: StockItem) => { + if (!toWarehouse) { alert("도착 창고를 먼저 선택하세요."); return; } + if (pendingItems.find((p) => p.stock.id === stock.id)) return; + const qty = parseFloat(stock.current_qty || "0"); + setPendingItems((prev) => [...prev, { stock, moveQty: qty, toWarehouse }]); + }; + + const removePending = (id: string) => { + setPendingItems((prev) => prev.filter((p) => p.stock.id !== id)); + }; + + const fromWh = warehouses.find((w) => w.warehouse_code === fromWarehouse); + const toWh = warehouses.find((w) => w.warehouse_code === toWarehouse); + + return ( + +
+ {/* Header */} +
+ +
+

📦 재고 이동

+

창고 간 재고를 이동합니다

+
+
+ + {/* 좌우 분할 */} +
+ {/* ===== 왼쪽: 출발 창고 + 품목 선택 ===== */} +
+ {/* 출발 창고 헤더 */} +
+
+ 📤 출발 창고 + FROM +
+
+ {warehouses.map((wh) => ( + + ))} +
+
+ + {/* 검색 */} + {fromWarehouse && ( +
+
+ setSearchKeyword(e.target.value)} + className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-blue-400" + /> + +
+
+ )} + + {/* 품목 리스트 */} +
+ {!fromWarehouse ? ( +
+ 📦 +

출발 창고를 선택하세요

+
+ ) : loading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +
+

해당 창고에 재고가 없습니다

+
+ ) : ( +
+ {filtered.map((item) => { + const isPending = pendingItems.some((p) => p.stock.id === item.id); + return ( + + ); + })} +
+ )} +
+
+ + {/* ===== 오른쪽: 도착 창고 + 이동 대기열 ===== */} +
+ {/* 도착 창고 헤더 */} +
+
+ 📥 도착 창고 + TO +
+
+ {warehouses + .filter((wh) => wh.warehouse_code !== fromWarehouse) + .map((wh) => ( + + ))} +
+
+ + {/* 이동 방향 표시 */} + {fromWh && toWh && ( +
+ {fromWh.warehouse_name} + + {toWh.warehouse_name} +
+ )} + + {/* 이동 대기 목록 */} +
+ {pendingItems.length === 0 ? ( +
+ 📋 +

왼쪽에서 품목을 선택하세요

+

선택한 품목이 여기에 표시됩니다

+
+ ) : ( +
+ {pendingItems.map((p) => ( +
+
+

{p.stock.item_name || p.stock.item_code}

+ +
+

{p.stock.item_code}

+
+ + {p.moveQty.toLocaleString()} EA + + + {p.stock.warehouse_code} → {p.toWarehouse} + +
+
+ ))} +
+ )} +
+ + {/* 하단 확정 바 */} +
+
+ 이동 대기: {pendingItems.length}건 +
+ +
+
+
+
+ + ); +} diff --git a/frontend/components/pop/hardcoded/inventory/InventoryTransfer.tsx b/frontend/components/pop/hardcoded/inventory/InventoryTransfer.tsx new file mode 100644 index 00000000..427b36ae --- /dev/null +++ b/frontend/components/pop/hardcoded/inventory/InventoryTransfer.tsx @@ -0,0 +1,252 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { apiClient } from "@/lib/api/client"; +import { PopShell } from "../PopShell"; + +interface Warehouse { + id: string; + warehouse_code: string; + warehouse_name: string; + warehouse_type?: string; +} + +interface StockItem { + id: string; + item_code: string; + item_name?: string; + warehouse_code: string; + location_code?: string; + current_qty: string; + unit?: string; +} + +interface SelectedItem { + stock: StockItem; + adjustQty: string; + type: "confirm" | "adjust"; +} + +export function InventoryTransfer() { + const router = useRouter(); + const [warehouses, setWarehouses] = useState([]); + const [selectedWarehouse, setSelectedWarehouse] = useState("all"); + const [stockItems, setStockItems] = useState([]); + const [loading, setLoading] = useState(true); + const [searchKeyword, setSearchKeyword] = useState(""); + const [selectedItems, setSelectedItems] = useState([]); + + const fetchWarehouses = useCallback(async () => { + try { + const res = await apiClient.get("/outbound/warehouses"); + setWarehouses(res.data?.data || []); + } catch { /* */ } + }, []); + + const fetchStock = useCallback(async () => { + setLoading(true); + try { + const params: Record = { pageSize: "500" }; + if (selectedWarehouse !== "all") { + params.filters = JSON.stringify({ warehouse_code: selectedWarehouse }); + } + const res = await apiClient.get("/data/inventory_stock", { params }); + const data = res.data?.data?.data ?? res.data?.data ?? []; + setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []); + } catch { + setStockItems([]); + } finally { + setLoading(false); + } + }, [selectedWarehouse]); + + useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]); + useEffect(() => { fetchStock(); }, [fetchStock]); + + const filtered = stockItems.filter((item) => { + if (!searchKeyword) return true; + const kw = searchKeyword.toLowerCase(); + return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw); + }); + + const addItem = (stock: StockItem) => { + if (selectedItems.find((s) => s.stock.id === stock.id)) return; + setSelectedItems((prev) => [...prev, { stock, adjustQty: "", type: "confirm" }]); + }; + + const removeItem = (id: string) => { + setSelectedItems((prev) => prev.filter((s) => s.stock.id !== id)); + }; + + const confirmCount = selectedItems.filter((s) => s.type === "confirm").length; + const adjustCount = selectedItems.filter((s) => s.type === "adjust").length; + + return ( + +
+ {/* Header */} +
+
+ +

📦 재고조정

+
+
+ + {/* Main — 2단 레이아웃 */} +
+ {/* 왼쪽: 제품 선택 */} +
+
+
+

📦 제품 선택

+ +
+ + {/* 창고 탭 */} +
+ + {warehouses.map((wh) => ( + + ))} +
+ + {/* 검색 */} +
+ setSearchKeyword(e.target.value)} + className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-amber-400" + /> + +
+
+ + {/* 품목 리스트 */} +
+ {loading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +
+ 📦 +

해당 창고에 재고가 없습니다

+
+ ) : ( +
+ {filtered.map((item) => ( +
+
+
📦
+
+

+ {item.item_name || item.item_code} + {item.item_name && ({item.item_code})} +

+

{item.warehouse_code}{item.location_code ? ` · ${item.location_code}` : ""}

+
+
+
+
+

{parseFloat(item.current_qty || "0").toLocaleString()}

+

{item.location_code || item.warehouse_code}

+
+ +
+
+ ))} +
+ )} +
+
+ + {/* 오른쪽: 처리 결과 */} +
+
+

📋 처리 결과

+ + {selectedItems.length}건 + +
+ +
+ {selectedItems.length === 0 ? ( +
+ 📋 +

제품을 스캔/선택하여 처리하세요

+
+ ) : ( +
+ {selectedItems.map((sel) => ( +
+
+

{sel.stock.item_name || sel.stock.item_code}

+ +
+

현재 재고: {parseFloat(sel.stock.current_qty || "0").toLocaleString()}

+ +
+ ))} +
+ )} +
+ + {/* Footer */} +
+
+ 확인 {confirmCount} + 조정 {adjustCount} +
+
+ + +
+
+
+
+
+ + ); +} diff --git a/frontend/components/pop/hardcoded/production/ProcessWork.tsx b/frontend/components/pop/hardcoded/production/ProcessWork.tsx index 6f603e43..748627a0 100644 --- a/frontend/components/pop/hardcoded/production/ProcessWork.tsx +++ b/frontend/components/pop/hardcoded/production/ProcessWork.tsx @@ -1286,26 +1286,6 @@ export function ProcessWork({ processId }: ProcessWorkProps) { )} -
-
- - - -
- - 실적 - -
)} @@ -2779,18 +2708,16 @@ function MaterialQtyInputRow({ }) { const [open, setOpen] = useState(false); return ( -
+
- {material.unit} setOpen(false)} @@ -2915,50 +2842,44 @@ function MaterialInputSection({ processId }: { processId: string }) { } return ( -
- {/* BOM 기준 자재 목록 */} -
-

- BOM 자재 목록 -

+
+ {/* BOM 기준 자재 목록 — 컴팩트 */} +
+
+

BOM 자재 목록

+ {bomMaterials.length}건 +
{bomMaterials.length === 0 ? (

BOM 자재 정보가 없습니다

) : ( -
- {bomMaterials.map((m) => ( -
-
-
-

- {m.child_item_name} -

-

{m.child_item_code}

-
-
-

소요량

-

- {m.required_qty} {m.unit} -

+
+
+ {bomMaterials.map((m) => ( +
+ {/* 자재명(코드) + 소요량 */} +
+ {m.child_item_name} + ({m.child_item_code}) + 소요 {m.required_qty}
+ {/* 입력 버튼 + 단위 */} + + setInputValues((prev) => ({ ...prev, [m.id]: v })) + } + /> + {m.unit}
- - setInputValues((prev) => ({ ...prev, [m.id]: v })) - } - /> -
- ))} + ))} +