Merge branch 'feature/production-inventory-link' into ksh-v2-work
This commit is contained in:
commit
4fa8c3969d
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
@ -761,9 +761,9 @@ export function PopCategoryTree({
|
|||
const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => {
|
||||
try {
|
||||
// 같은 부모의 형제 그룹들 찾기
|
||||
const parentId = targetGroup.parent_id;
|
||||
const parentId = targetGroup.parent_group_id;
|
||||
const siblingGroups = groups
|
||||
.filter((g) => g.parent_id === parentId)
|
||||
.filter((g) => g.parent_group_id === parentId)
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
|
||||
|
||||
const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id);
|
||||
|
|
@ -792,9 +792,9 @@ export function PopCategoryTree({
|
|||
const handleMoveGroupDown = async (targetGroup: PopScreenGroup) => {
|
||||
try {
|
||||
// 같은 부모의 형제 그룹들 찾기
|
||||
const parentId = targetGroup.parent_id;
|
||||
const parentId = targetGroup.parent_group_id;
|
||||
const siblingGroups = groups
|
||||
.filter((g) => g.parent_id === parentId)
|
||||
.filter((g) => g.parent_group_id === parentId)
|
||||
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
|
||||
|
||||
const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue