feat: 재고입고 독립API + 실적/입고 탭 분리 + 카테고리 트리 UI 수정

- popProductionController: inventoryInbound 독립 API (이중입고 방지)
- popProductionRoutes: POST /inventory-inbound 라우트 추가
- PopWorkDetailComponent: 실적/입고 탭 완전 분리, 인라인 재고입고 UI
- PopWorkDetailConfig: 재고입고 설정 옵션 (기본창고, 고정, 필수)
- types.ts: InventoryInboundConfig 인터페이스 추가
- PopCategoryTree: 들여쓰기 축소(24→16px) + ⋮ 버튼 ml-auto
This commit is contained in:
SeongHyun Kim 2026-03-31 01:37:29 +09:00
parent ee8c1eb0df
commit 741fef148c
6 changed files with 575 additions and 181 deletions

View File

@ -1859,3 +1859,158 @@ export const updateTargetWarehouse = async (
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* API
* + inventory_stock UPSERT를 .
* (save-result) .
* 방지: target_warehouse_id가 "이미 입고됨" .
*/
export const inventoryInbound = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, warehouse_code, location_code } = req.body;
if (!work_order_process_id || !warehouse_code) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 warehouse_code는 필수입니다.",
});
}
await client.query("BEGIN");
// 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회
const procResult = await client.query(
`SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no
FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
if (procResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "해당 공정을 찾을 수 없습니다.",
});
}
const proc = procResult.rows[0];
// 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부
if (proc.target_warehouse_id) {
await client.query("ROLLBACK");
return res.status(409).json({
success: false,
message: "이미 재고 입고가 완료된 공정입니다.",
data: { existing_warehouse: proc.target_warehouse_id },
});
}
const goodQty = parseInt(proc.good_qty || "0", 10) + parseInt(proc.concession_qty || "0", 10);
if (goodQty <= 0) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: "양품 수량이 0이므로 재고 입고할 수 없습니다.",
});
}
// 2. work_instruction에서 item_id 조회
const wiResult = await client.query(
`SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`,
[proc.wo_id, companyCode]
);
if (wiResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "작업지시를 찾을 수 없습니다.",
});
}
const itemId = wiResult.rows[0].item_id;
// 3. item_info에서 item_number 조회
const itemResult = await client.query(
`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`,
[itemId, companyCode]
);
if (itemResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "품목 정보를 찾을 수 없습니다.",
});
}
const itemCode = itemResult.rows[0].item_number;
const effectiveLocationCode = location_code || warehouse_code;
// 4. inventory_stock UPSERT
await client.query(
`INSERT INTO inventory_stock (id, company_code, item_code, warehouse_code, location_code, current_qty, last_in_date, created_date, updated_date, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, NOW(), NOW(), NOW(), $6)
ON CONFLICT (company_code, item_code, warehouse_code, location_code)
DO UPDATE SET current_qty = (COALESCE(inventory_stock.current_qty::numeric, 0) + $5::numeric)::text,
last_in_date = NOW(),
updated_date = NOW(),
writer = $6`,
[companyCode, itemCode, warehouse_code, effectiveLocationCode, String(goodQty), userId]
);
// 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행)
const idsToUpdate = [work_order_process_id];
if (proc.parent_process_id) {
idsToUpdate.push(proc.parent_process_id);
}
for (const id of idsToUpdate) {
await client.query(
`UPDATE work_order_process
SET target_warehouse_id = $3,
target_location_code = $4,
writer = $5,
updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[id, companyCode, warehouse_code, location_code || null, userId]
);
}
await client.query("COMMIT");
logger.info("[pop/production] 독립 재고 입고 완료", {
companyCode, userId, work_order_process_id,
itemCode, warehouse_code, location_code: effectiveLocationCode,
qty: goodQty,
});
return res.json({
success: true,
message: "재고 입고가 완료되었습니다.",
data: {
item_code: itemCode,
warehouse_code,
location_code: effectiveLocationCode,
qty: goodQty,
},
});
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
logger.error("[pop/production] 독립 재고 입고 오류:", error);
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
};

View File

@ -15,6 +15,7 @@ import {
getWarehouseLocations,
isLastProcess,
updateTargetWarehouse,
inventoryInbound,
} from "../controllers/popProductionController";
const router = Router();
@ -35,5 +36,6 @@ router.get("/warehouses", getWarehouses);
router.get("/warehouse-locations/:warehouseId", getWarehouseLocations);
router.get("/is-last-process/:processId", isLastProcess);
router.post("/update-target-warehouse", updateTargetWarehouse);
router.post("/inventory-inbound", inventoryInbound);
export default router;

View File

@ -197,7 +197,7 @@ function TreeNode({
isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted",
"group"
)}
style={{ paddingLeft: `${level * 24 + 8}px` }}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onGroupSelect(group)}
>
{/* 트리 연결 표시 (하위 레벨만) */}
@ -254,7 +254,7 @@ function TreeNode({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
@ -373,7 +373,7 @@ function TreeNode({
: "hover:bg-muted",
"group"
)}
style={{ paddingLeft: `${(level + 1) * 24 + 8}px` }}
style={{ paddingLeft: `${(level + 1) * 16 + 8}px` }}
onClick={() => onScreenSelect(screen)}
onDoubleClick={() => onScreenDesign(screen)}
>

View File

@ -4,7 +4,7 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"
import {
Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package,
ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList,
Plus, Trash2, Save, FileCheck, Construction, Zap, RefreshCw,
Plus, Trash2, Save, FileCheck, Construction, Zap, RefreshCw, Warehouse,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -17,13 +17,11 @@ import { dataApi } from "@/lib/api/data";
import { apiClient } from "@/lib/api/client";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useAuth } from "@/hooks/useAuth";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
// Dialog import 제거 — 창고 선택이 인라인 방식으로 변경됨
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import type { PopWorkDetailConfig, ResultSectionConfig, ResultSectionType, PlcDataConfig } from "../types";
import type { PopWorkDetailConfig, ResultSectionConfig, ResultSectionType, PlcDataConfig, InventoryInboundConfig } from "../types";
import type { TimelineProcessStep } from "../types";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
@ -311,8 +309,13 @@ export function PopWorkDetailComponent({
stepControl: { ...DEFAULT_CFG.stepControl, ...config?.stepControl },
navigation: { ...DEFAULT_CFG.navigation, ...config?.navigation },
phaseLabels: { ...DEFAULT_CFG.phaseLabels, ...config?.phaseLabels },
inventoryConfig: config?.inventoryConfig,
};
// 재고 입고 설정
const inventoryConfig = cfg.inventoryConfig;
const enableInventory = inventoryConfig?.enableInventoryInbound ?? true;
const parentRow = parentRowProp ?? getSharedData<RowData>("parentRow");
const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined;
const currentProcess = processFlow?.find((p) => p.isCurrent);
@ -342,23 +345,24 @@ export function PopWorkDetailComponent({
const [currentItemIdx, setCurrentItemIdx] = useState(0);
const [showQuantityPanel, setShowQuantityPanel] = useState(false);
// 탭 상태: 상단 탭 (작업 전 | 작업 중 | 작업 후 | 실적)
// 탭 상태: 상단 탭 (작업 전 | 작업 중 | 작업 후 | 실적 | 재고 입고)
const [activePhaseTab, setActivePhaseTab] = useState<string | null>(null);
const [resultTabActive, setResultTabActive] = useState(false);
const [inventoryTabActive, setInventoryTabActive] = useState(false);
const hasResultSections = !!(cfg.resultSections && cfg.resultSections.some((s) => s.enabled));
// 불량 유형 목록 (부모에서 1회 로드, ResultPanel에 전달)
const [cachedDefectTypes, setCachedDefectTypes] = useState<DefectTypeOption[]>([]);
// 창고 선택 모달 상태
const [showWarehouseModal, setShowWarehouseModal] = useState(false);
// 창고 선택 (인라인 방식 - 마지막 공정일 때만 표시)
const [isLastProcess, setIsLastProcess] = useState(false);
const [inventoryDone, setInventoryDone] = useState(false);
const [warehouses, setWarehouses] = useState<{ id: string; warehouse_code: string; warehouse_name: string; warehouse_type: string }[]>([]);
const [locations, setLocations] = useState<{ id: string; location_code: string; location_name: string }[]>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState("");
const [selectedLocation, setSelectedLocation] = useState("");
const [useDefaultWarehouse, setUseDefaultWarehouse] = useState(false);
const [warehouseSaving, setWarehouseSaving] = useState(false);
const [pendingCompletionData, setPendingCompletionData] = useState<Partial<ProcessTimerData> | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
@ -446,31 +450,55 @@ export function PopWorkDetailComponent({
}
}, [selectedWarehouse, loadLocations]);
/**
*
* ResultPanel의 onSaved handleTimerAction("complete")
*/
const checkLastProcessAndShowWarehouse = useCallback(async (completionData: Partial<ProcessTimerData>) => {
if (!workOrderProcessId) return false;
try {
const res = await apiClient.get(`/pop/production/is-last-process/${workOrderProcessId}`);
if (res.data?.success && res.data.data?.isLast) {
const { targetWarehouseId, targetLocationCode } = res.data.data;
// 이미 창고가 설정되어 있으면 모달 건너뛰기
if (targetWarehouseId) {
return false;
// 마지막 공정 자동 감지
useEffect(() => {
if (!workOrderProcessId) return;
apiClient.get(`/pop/production/is-last-process/${workOrderProcessId}`)
.then(res => {
if (res.data?.success) {
setIsLastProcess(res.data.data?.isLast ?? false);
// 이미 창고가 설정되어 있으면 입고 완료 표시
if (res.data.data?.targetWarehouseId) {
setInventoryDone(true);
}
}
// 창고 목록 로드 후 모달 표시
await loadWarehouses();
setPendingCompletionData(completionData);
setShowWarehouseModal(true);
return true;
})
.catch(() => setIsLastProcess(false));
}, [workOrderProcessId]);
// 마지막 공정이고 입고 미완료일 때 창고 목록 자동 로드
useEffect(() => {
if (!isLastProcess || inventoryDone || !enableInventory) return;
loadWarehouses();
// 1순위: inventoryConfig의 기본 창고 설정
if (inventoryConfig?.defaultWarehouseId) {
setSelectedWarehouse(inventoryConfig.defaultWarehouseId);
setUseDefaultWarehouse(true);
loadLocations(inventoryConfig.defaultWarehouseId);
if (inventoryConfig.defaultLocationCode) {
setSelectedLocation(inventoryConfig.defaultLocationCode);
}
} catch {
// 확인 실패 시 모달 없이 진행
return;
}
return false;
}, [workOrderProcessId, loadWarehouses]);
// 2순위: localStorage 기본 창고 복원
const saved = localStorage.getItem("pop_default_warehouse");
if (saved) {
try {
const def = JSON.parse(saved);
if (def.warehouse_code) {
if (def.id) {
setSelectedWarehouse(def.id);
setUseDefaultWarehouse(true);
loadLocations(def.id);
}
if (def.location_code) setSelectedLocation(def.location_code);
}
} catch { /* 파싱 실패 무시 */ }
}
}, [isLastProcess, inventoryDone, enableInventory, inventoryConfig, loadWarehouses, loadLocations]);
const handleWarehouseConfirm = useCallback(async () => {
if (!workOrderProcessId || !selectedWarehouse) {
@ -483,10 +511,11 @@ export function PopWorkDetailComponent({
const wh = warehouses.find((w) => w.id === selectedWarehouse);
const warehouseCode = wh?.warehouse_code || selectedWarehouse;
await apiClient.post("/pop/production/update-target-warehouse", {
// 독립 재고 입고 API 호출 (창고 저장 + inventory_stock UPSERT 한번에)
await apiClient.post("/pop/production/inventory-inbound", {
work_order_process_id: workOrderProcessId,
target_warehouse_id: warehouseCode,
target_location_code: selectedLocation || null,
warehouse_code: warehouseCode,
location_code: selectedLocation || null,
});
// 기본 창고 설정 (로컬 스토리지)
@ -499,41 +528,15 @@ export function PopWorkDetailComponent({
}));
}
toast.success("입고 창고가 설정되었습니다.");
setShowWarehouseModal(false);
// 보류 중이던 완료 데이터 반영
if (pendingCompletionData) {
setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev);
publish("process_completed", { workOrderProcessId, ...pendingCompletionData });
setPendingCompletionData(null);
}
} catch {
toast.error("창고 설정에 실패했습니다.");
toast.success("재고 입고가 완료되었습니다.");
setInventoryDone(true);
} catch (err: any) {
const msg = err?.response?.data?.message || "재고 입고에 실패했습니다.";
toast.error(msg);
} finally {
setWarehouseSaving(false);
}
}, [workOrderProcessId, selectedWarehouse, selectedLocation, useDefaultWarehouse, warehouses, pendingCompletionData, publish]);
// 모달 열릴 때 기본 창고 확인
useEffect(() => {
if (showWarehouseModal) {
const saved = localStorage.getItem("pop_default_warehouse");
if (saved) {
try {
const def = JSON.parse(saved);
if (def.warehouse_code) {
const found = warehouses.find((w) => w.warehouse_code === def.warehouse_code);
if (found) {
setSelectedWarehouse(found.id);
if (def.location_code) setSelectedLocation(def.location_code);
setUseDefaultWarehouse(true);
}
}
} catch { /* 파싱 실패 무시 */ }
}
}
}, [showWarehouseModal, warehouses]);
}, [workOrderProcessId, selectedWarehouse, selectedLocation, useDefaultWarehouse, warehouses]);
// ========================================
// 좌측 사이드바 - 작업항목 그룹핑
@ -906,23 +909,17 @@ export function PopWorkDetailComponent({
});
const proc = (res.data?.[0] ?? null) as ProcessTimerData | null;
if (proc) {
setProcessData(proc);
if (action === "complete") {
// 마지막 공정이면 창고 선택 모달 표시
const shown = await checkLastProcessAndShowWarehouse(proc);
if (!shown) {
setProcessData(proc);
toast.success("공정이 완료되었습니다.");
publish("process_completed", { workOrderProcessId, goodQty, defectQty });
}
} else {
setProcessData(proc);
toast.success("공정이 완료되었습니다.");
publish("process_completed", { workOrderProcessId, goodQty, defectQty });
}
}
} catch {
toast.error("타이머 제어에 실패했습니다.");
}
},
[workOrderProcessId, goodQty, defectQty, publish, checkLastProcessAndShowWarehouse]
[workOrderProcessId, goodQty, defectQty, publish]
);
// ========================================
@ -979,6 +976,15 @@ export function PopWorkDetailComponent({
const handleResultTabClick = useCallback(() => {
setResultTabActive(true);
setInventoryTabActive(false);
setActivePhaseTab(null);
setSelectedGroupId(null);
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const handleInventoryTabClick = useCallback(() => {
setInventoryTabActive(true);
setResultTabActive(false);
setActivePhaseTab(null);
setSelectedGroupId(null);
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
@ -1123,7 +1129,7 @@ export function PopWorkDetailComponent({
{/* 그룹 항목 */}
<div className="space-y-0.5">
{phaseGrps.map((g) => {
const isSelected = selectedGroupId === g.itemId && !resultTabActive;
const isSelected = selectedGroupId === g.itemId && !resultTabActive && !inventoryTabActive;
return (
<button
key={g.itemId}
@ -1137,6 +1143,7 @@ export function PopWorkDetailComponent({
setSelectedGroupId(g.itemId);
setActivePhaseTab(g.phase);
setResultTabActive(false);
setInventoryTabActive(false);
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
>
@ -1175,7 +1182,7 @@ export function PopWorkDetailComponent({
{/* 실적 그룹 */}
{hasResultSections && (
<div>
<div className="mb-5">
<div className="mb-2 flex items-center gap-2 px-4">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-500">
<ClipboardList className="h-3 w-3 text-white" strokeWidth={2.5} />
@ -1189,14 +1196,47 @@ export function PopWorkDetailComponent({
<button
className={cn(
"flex w-full items-center gap-2.5 rounded-r-lg py-2 pl-4 pr-2 text-left transition-all duration-150 hover:bg-black/[0.04]",
resultTabActive && "border-l-[3px] border-l-blue-500 bg-blue-500/[0.06]",
!resultTabActive && "border-l-[3px] border-l-transparent"
resultTabActive && !inventoryTabActive && "border-l-[3px] border-l-blue-500 bg-blue-500/[0.06]",
!(resultTabActive && !inventoryTabActive) && "border-l-[3px] border-l-transparent"
)}
style={resultTabActive ? { paddingLeft: 13 } : undefined}
style={resultTabActive && !inventoryTabActive ? { paddingLeft: 13 } : undefined}
onClick={handleResultTabClick}
>
<AlertCircle className={cn("h-4 w-4 shrink-0", resultTabActive ? "text-blue-500" : "text-amber-500")} />
<span className={cn("text-sm", resultTabActive ? "font-medium text-blue-700" : "font-medium text-amber-700")}> </span>
<AlertCircle className={cn("h-4 w-4 shrink-0", resultTabActive && !inventoryTabActive ? "text-blue-500" : "text-amber-500")} />
<span className={cn("text-sm", resultTabActive && !inventoryTabActive ? "font-medium text-blue-700" : "font-medium text-amber-700")}> </span>
</button>
</div>
</div>
)}
{/* 재고 입고 그룹 - 마지막 공정일 때만 표시 */}
{isLastProcess && enableInventory && (
<div>
<div className="mb-2 flex items-center gap-2 px-4">
<div className={cn(
"flex h-5 w-5 items-center justify-center rounded-full",
inventoryDone ? "bg-green-500" : "bg-amber-500"
)}>
<Warehouse className="h-3 w-3 text-white" strokeWidth={2.5} />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-900"></span>
<span className={cn("ml-auto text-xs font-medium", inventoryDone ? "text-green-600" : "text-amber-600")}>
{inventoryDone ? "완료" : "미완료"}
</span>
</div>
<div className="space-y-0.5">
<button
className={cn(
"flex w-full items-center gap-2.5 rounded-r-lg py-2 pl-4 pr-2 text-left transition-all duration-150 hover:bg-black/[0.04]",
inventoryTabActive && "border-l-[3px] border-l-blue-500 bg-blue-500/[0.06]",
!inventoryTabActive && "border-l-[3px] border-l-transparent"
)}
style={inventoryTabActive ? { paddingLeft: 13 } : undefined}
onClick={handleInventoryTabClick}
>
<Warehouse className={cn("h-4 w-4 shrink-0", inventoryTabActive ? "text-blue-500" : inventoryDone ? "text-green-500" : "text-amber-500")} />
<span className={cn("text-sm", inventoryTabActive ? "font-medium text-blue-700" : inventoryDone ? "font-medium text-green-700" : "font-medium text-amber-700")}> </span>
{inventoryDone && <Check className="ml-auto h-3.5 w-3.5 text-green-500" />}
</button>
</div>
</div>
@ -1216,11 +1256,6 @@ export function PopWorkDetailComponent({
isProcessCompleted={isProcessCompleted}
defectTypes={cachedDefectTypes}
onSaved={async (updated) => {
// 자동 완료 시 마지막 공정이면 창고 선택 모달 표시
if (updated?.status === "completed") {
const shown = await checkLastProcessAndShowWarehouse(updated);
if (shown) return; // 모달이 뜨면 여기서 중단, 확인 후 반영
}
setProcessData((prev) => prev ? { ...prev, ...updated } : prev);
publish("process_completed", { workOrderProcessId, status: updated?.status });
}}
@ -1228,8 +1263,142 @@ export function PopWorkDetailComponent({
</div>
)}
{/* 재고 입고 패널 (사이드바에서 선택 시 표시) - 실적과 완전 독립 */}
{isLastProcess && enableInventory && (
<div className={cn("flex flex-1 flex-col overflow-hidden", !inventoryTabActive && "hidden")}>
<div ref={contentRef} className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-lg space-y-6">
{/* 헤더 */}
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100">
<Warehouse className="h-5 w-5 text-amber-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900"> </h3>
<p className="text-sm text-gray-500"> </p>
</div>
{inventoryDone && <Badge variant="outline" className="ml-auto border-green-600 text-green-600"> </Badge>}
</div>
{/* 현재 실적 요약 */}
<div className="rounded-xl border border-gray-200 bg-white p-5">
<div className="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-400"> </div>
<div className="flex items-center gap-8">
<div className="flex flex-col items-center">
<span className="text-3xl font-bold text-green-600" style={{ fontVariantNumeric: 'tabular-nums' }}>
{parseInt(processData?.good_qty ?? "0", 10) || 0}
</span>
<span className="mt-1 text-sm font-medium text-green-500"></span>
</div>
<div className="h-8 w-px bg-gray-200" />
<div className="flex flex-col items-center">
<span className="text-3xl font-bold text-red-600" style={{ fontVariantNumeric: 'tabular-nums' }}>
{parseInt(processData?.defect_qty ?? "0", 10) || 0}
</span>
<span className="mt-1 text-sm font-medium text-red-500"></span>
</div>
<div className="h-8 w-px bg-gray-200" />
<div className="flex flex-col items-center">
<span className="text-3xl font-bold text-gray-900" style={{ fontVariantNumeric: 'tabular-nums' }}>
{parseInt(processData?.total_production_qty ?? "0", 10) || 0}
</span>
<span className="mt-1 text-sm font-medium text-gray-400"> </span>
</div>
</div>
</div>
{/* 실적이 없으면 안내 메시지 */}
{(parseInt(processData?.good_qty ?? "0", 10) || 0) <= 0 && !inventoryDone ? (
<div className="flex flex-col items-center gap-3 rounded-xl border-2 border-dashed border-amber-300 bg-amber-50 p-8">
<AlertCircle className="h-10 w-10 text-amber-500" />
<p className="text-base font-semibold text-amber-700"> </p>
<p className="text-sm text-amber-600"> 0. .</p>
</div>
) : !inventoryDone ? (
<div className="space-y-5 rounded-xl border border-gray-200 bg-white p-5">
{/* 창고 선택 */}
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700"></label>
<Select
value={selectedWarehouse}
onValueChange={setSelectedWarehouse}
disabled={inventoryConfig?.lockWarehouse === true}
>
<SelectTrigger style={{ height: `${DESIGN.input.height}px` }}>
<SelectValue placeholder="창고를 선택하세요" />
</SelectTrigger>
<SelectContent>
{warehouses.map(w => (
<SelectItem key={w.id} value={w.id}>{w.warehouse_name} ({w.warehouse_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 위치 선택 */}
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700"> <span className="text-gray-400">()</span></label>
<Select
value={selectedLocation}
onValueChange={setSelectedLocation}
disabled={inventoryConfig?.lockWarehouse === true}
>
<SelectTrigger style={{ height: `${DESIGN.input.height}px` }}>
<SelectValue placeholder="위치를 선택하세요" />
</SelectTrigger>
<SelectContent>
{locations.map(l => (
<SelectItem key={l.location_code} value={l.location_code}>{l.location_name} ({l.location_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 기본 창고 체크박스 */}
<label className="flex items-center gap-2.5 rounded-lg border border-gray-100 bg-gray-50 p-3">
<Checkbox
checked={useDefaultWarehouse}
onCheckedChange={(v) => setUseDefaultWarehouse(v === true)}
className="rounded"
disabled={inventoryConfig?.lockWarehouse === true}
/>
<span className="text-sm text-gray-600"> </span>
</label>
{/* 입고 버튼 - 양품 수량 표시 */}
<GlossyButton
variant="yellow"
onClick={handleWarehouseConfirm}
disabled={!selectedWarehouse || warehouseSaving}
className="w-full"
style={{ minHeight: DESIGN.button.height }}
>
{warehouseSaving ? <Loader2 className="mr-2 h-5 w-5 animate-spin" /> : <Warehouse className="mr-2 h-5 w-5" />}
{warehouseSaving
? "처리 중..."
: `재고 입고 (양품 ${parseInt(processData?.good_qty ?? "0", 10) || 0}개)`
}
</GlossyButton>
</div>
) : (
<div className="flex flex-col items-center gap-4 rounded-xl border border-green-200 bg-green-50 p-8">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<p className="text-lg font-semibold text-green-700">
{parseInt(processData?.good_qty ?? "0", 10) || 0} {(() => {
const wh = warehouses.find((w) => w.id === selectedWarehouse);
return wh ? `${wh.warehouse_name} 창고에` : "지정된 창고에";
})()}
</p>
<p className="text-sm text-green-600"> .</p>
</div>
)}
</div>
</div>
</div>
)}
{/* 체크리스트 영역 */}
<div className={cn("flex flex-1 flex-col overflow-hidden", resultTabActive && "hidden")}>
<div className={cn("flex flex-1 flex-col overflow-hidden", (resultTabActive || inventoryTabActive) && "hidden")}>
{cfg.displayMode === "step" ? (
/* ======== 스텝 모드 ======== */
<>
@ -1474,93 +1643,6 @@ export function PopWorkDetailComponent({
</div>
)}
{/* 창고 선택 모달 (마지막 공정 완료 시) */}
<Dialog open={showWarehouseModal} onOpenChange={(open) => {
if (!open && pendingCompletionData) {
// 모달 닫기 시 보류 중이던 완료 데이터 반영 (창고 미선택)
setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev);
publish("process_completed", { workOrderProcessId, ...pendingCompletionData });
setPendingCompletionData(null);
}
setShowWarehouseModal(open);
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" />
</DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"></label>
<Select value={selectedWarehouse} onValueChange={setSelectedWarehouse}>
<SelectTrigger className="h-12">
<SelectValue placeholder="창고를 선택하세요" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.warehouse_name} ({w.warehouse_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{locations.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"> ()</label>
<Select value={selectedLocation} onValueChange={setSelectedLocation}>
<SelectTrigger className="h-12">
<SelectValue placeholder="위치를 선택하세요" />
</SelectTrigger>
<SelectContent>
{locations.map((l) => (
<SelectItem key={l.location_code} value={l.location_code}>
{l.location_name} ({l.location_code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<label className="flex items-center gap-2 pt-2">
<Checkbox
checked={useDefaultWarehouse}
onCheckedChange={(v) => setUseDefaultWarehouse(v === true)}
/>
<span className="text-sm text-gray-600"> </span>
</label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowWarehouseModal(false);
if (pendingCompletionData) {
setProcessData((prev) => prev ? { ...prev, ...pendingCompletionData } : prev);
publish("process_completed", { workOrderProcessId, ...pendingCompletionData });
setPendingCompletionData(null);
}
}}
>
</Button>
<Button
onClick={handleWarehouseConfirm}
disabled={!selectedWarehouse || warehouseSaving}
>
{warehouseSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@ -1608,6 +1690,8 @@ interface ResultPanelProps {
isProcessCompleted: boolean;
defectTypes: DefectTypeOption[];
onSaved: (updated: Partial<ProcessTimerData>) => void;
/** 실적 등록 전 검증 콜백. false 반환 시 저장 차단. */
onBeforeSave?: () => boolean;
}
function ResultPanel({
@ -1617,6 +1701,7 @@ function ResultPanel({
isProcessCompleted,
defectTypes,
onSaved,
onBeforeSave,
}: ResultPanelProps) {
// 이번 차수 입력값 (누적치가 아닌 이번에 생산한 수량)
const [batchQty, setBatchQty] = useState("");
@ -1727,6 +1812,7 @@ function ResultPanel({
};
const handleSubmitBatch = async () => {
if (onBeforeSave && !onBeforeSave()) return;
if (!batchQty || parseInt(batchQty, 10) <= 0) {
toast.error("생산수량을 입력해주세요.");
return;

View File

@ -6,14 +6,17 @@ import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, ChevronUp, ChevronDown, Zap, Loader2 } from "lucide-react";
import { Plus, Trash2, ChevronUp, ChevronDown, Zap, Loader2, Warehouse } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
import { apiClient } from "@/lib/api/client";
import type {
PopWorkDetailConfig,
WorkDetailInfoBarField,
ResultSectionConfig,
ResultSectionType,
PlcDataConfig,
InventoryInboundConfig,
} from "../types";
interface PopWorkDetailConfigPanelProps {
@ -85,6 +88,7 @@ export function PopWorkDetailConfigPanel({
stepControl: config?.stepControl ?? { ...DEFAULT_STEP_CONTROL },
navigation: config?.navigation ?? { ...DEFAULT_NAVIGATION },
resultSections: config?.resultSections ?? [],
inventoryConfig: config?.inventoryConfig,
};
const update = (partial: Partial<PopWorkDetailConfig>) => {
@ -94,6 +98,37 @@ export function PopWorkDetailConfigPanel({
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldColumn, setNewFieldColumn] = useState("");
// --- 재고 입고 설정: 창고/위치 목록 ---
const [warehouseOptions, setWarehouseOptions] = useState<{ id: string; warehouse_code: string; warehouse_name: string }[]>([]);
const [locationOptions, setLocationOptions] = useState<{ id: string; location_code: string; location_name: string }[]>([]);
const [warehouseLoading, setWarehouseLoading] = useState(false);
const [locationLoading, setLocationLoading] = useState(false);
const inventoryEnabled = cfg.inventoryConfig?.enableInventoryInbound ?? true;
useEffect(() => {
if (!inventoryEnabled) return;
setWarehouseLoading(true);
apiClient.get("/pop/production/warehouses")
.then(res => setWarehouseOptions(res.data?.data ?? []))
.catch(() => setWarehouseOptions([]))
.finally(() => setWarehouseLoading(false));
}, [inventoryEnabled]);
useEffect(() => {
const whId = cfg.inventoryConfig?.defaultWarehouseId;
if (!whId) { setLocationOptions([]); return; }
setLocationLoading(true);
apiClient.get(`/pop/production/warehouse-locations/${whId}`)
.then(res => setLocationOptions(res.data?.data ?? []))
.catch(() => setLocationOptions([]))
.finally(() => setLocationLoading(false));
}, [cfg.inventoryConfig?.defaultWarehouseId]);
const updateInventoryConfig = (partial: Partial<InventoryInboundConfig>) => {
update({ inventoryConfig: { ...cfg.inventoryConfig, ...partial } });
};
const addInfoBarField = () => {
if (!newFieldLabel || !newFieldColumn) return;
const fields = [...(cfg.infoBar.fields ?? []), { label: newFieldLabel, column: newFieldColumn }];
@ -317,6 +352,104 @@ export function PopWorkDetailConfigPanel({
</div>
))}
</Section>
{/* 재고 입고 설정 */}
<div className="space-y-3 border-t pt-4 mt-4">
<h4 className="text-sm font-semibold flex items-center gap-2">
<Warehouse className="h-4 w-4" />
</h4>
<label className="flex items-center gap-2">
<Checkbox
checked={cfg.inventoryConfig?.enableInventoryInbound ?? true}
onCheckedChange={(v) => updateInventoryConfig({ enableInventoryInbound: !!v })}
/>
<span className="text-sm"> </span>
</label>
{inventoryEnabled && (
<div className="space-y-3 pl-1">
{/* 기본 창고 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
{warehouseLoading ? (
<div className="flex items-center gap-1 text-xs text-gray-400">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : (
<Select
value={cfg.inventoryConfig?.defaultWarehouseId || ""}
onValueChange={(v) => {
const wh = warehouseOptions.find(w => w.id === v);
updateInventoryConfig({
defaultWarehouseId: v,
defaultWarehouseName: wh?.warehouse_name ?? "",
defaultLocationCode: "",
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="창고 선택" />
</SelectTrigger>
<SelectContent>
{warehouseOptions.map((w) => (
<SelectItem key={w.id} value={w.id} className="text-xs">
{w.warehouse_name} ({w.warehouse_code})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* 기본 위치 */}
<div className="space-y-1">
<span className="text-xs text-gray-600"> </span>
{locationLoading ? (
<div className="flex items-center gap-1 text-xs text-gray-400">
<Loader2 className="h-3 w-3 animate-spin" /> ...
</div>
) : (
<Select
value={cfg.inventoryConfig?.defaultLocationCode || ""}
onValueChange={(v) => updateInventoryConfig({ defaultLocationCode: v })}
disabled={!cfg.inventoryConfig?.defaultWarehouseId}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={cfg.inventoryConfig?.defaultWarehouseId ? "위치 선택" : "창고를 먼저 선택하세요"} />
</SelectTrigger>
<SelectContent>
{locationOptions.map((l) => (
<SelectItem key={l.location_code} value={l.location_code} className="text-xs">
{l.location_name} ({l.location_code})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* 창고 선택 고정 */}
<label className="flex items-center gap-2">
<Checkbox
checked={cfg.inventoryConfig?.lockWarehouse ?? false}
onCheckedChange={(v) => updateInventoryConfig({ lockWarehouse: !!v })}
/>
<span className="text-sm"> ( )</span>
</label>
{/* 실적 등록 전 창고 선택 필수 */}
<label className="flex items-center gap-2">
<Checkbox
checked={cfg.inventoryConfig?.requireWarehouseBeforeSave ?? true}
onCheckedChange={(v) => updateInventoryConfig({ requireWarehouseBeforeSave: !!v })}
/>
<span className="text-sm"> </span>
</label>
</div>
)}
</div>
</div>
);
}
@ -384,7 +517,8 @@ function PlcDataSettingsPanel({ plcConfig, onChange }: PlcDataSettingsPanelProps
setTablesLoading(true);
try {
const res = await ExternalDbConnectionAPI.getTables(Number(connId));
setTables(res.data ?? []);
const raw = res.data ?? [];
setTables(raw.map((t: any) => typeof t === 'string' ? t : t.table_name ?? String(t)));
} catch {
setTables([]);
} finally {

View File

@ -1113,6 +1113,21 @@ export interface ResultSectionConfig {
plcConfig?: PlcDataConfig;
}
export interface InventoryInboundConfig {
/** 마지막 공정 완료 시 재고 입고 사용 여부 */
enableInventoryInbound?: boolean;
/** 기본 창고 ID */
defaultWarehouseId?: string;
/** 기본 창고 이름 (표시용) */
defaultWarehouseName?: string;
/** 기본 위치 코드 */
defaultLocationCode?: string;
/** 창고 선택 고정 (사용자 변경 불가) */
lockWarehouse?: boolean;
/** 실적 등록 전 창고 선택 필수 */
requireWarehouseBeforeSave?: boolean;
}
export interface PopWorkDetailConfig {
showTimer: boolean;
/** @deprecated result-input 타입으로 대체 */
@ -1124,4 +1139,6 @@ export interface PopWorkDetailConfig {
stepControl: WorkDetailStepControl;
navigation: WorkDetailNavigationConfig;
resultSections?: ResultSectionConfig[];
/** 재고 입고 설정 */
inventoryConfig?: InventoryInboundConfig;
}