diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 268b8274..86156ca5 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -201,13 +201,32 @@ export async function create(req: AuthenticatedRequest, res: Response) { // 재고 레코드가 없으면 0으로 생성 (마이너스 방지) await client.query( `INSERT INTO inventory_stock ( - company_code, item_code, warehouse_code, location_code, + id, company_code, item_code, warehouse_code, location_code, current_qty, safety_qty, last_out_date, created_date, updated_date, writer - ) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`, + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`, [companyCode, itemCode, whCode, locCode, userId] ); } + + // 재고 이력 기록 (inventory_history) + const afterStockRes = await client.query( + `SELECT current_qty FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + const afterQty = afterStockRes.rows[0]?.current_qty || '0'; + await client.query( + `INSERT INTO inventory_history ( + id, company_code, item_number, warehouse_code, location_code, + history_type, history_date, change_qty, after_qty, reason, + created_by, created_date + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '출고', NOW()::date, $5, $6, $7, $8, NOW())`, + [companyCode, itemCode, whCode, locCode, String(-outQty), afterQty, item.outbound_type || '출고', userId] + ); } // 판매출고인 경우 출하지시의 ship_qty 업데이트 diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 51986e3c..ff892318 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -253,6 +253,25 @@ export async function create(req: AuthenticatedRequest, res: Response) { [companyCode, itemCode, whCode, locCode, String(inQty), userId] ); } + + // 2b-2. 재고 이력 기록 (inventory_history) + const afterStockRes = await client.query( + `SELECT current_qty FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + const afterQty = afterStockRes.rows[0]?.current_qty || String(inQty); + await client.query( + `INSERT INTO inventory_history ( + id, company_code, item_number, warehouse_code, location_code, + history_type, history_date, change_qty, after_qty, reason, + created_by, created_date + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고', NOW()::date, $5, $6, $7, $8, NOW())`, + [companyCode, itemCode, whCode, locCode, String(inQty), afterQty, item.inbound_type || '입고', userId] + ); } // 2c. 구매입고인 경우 발주의 received_qty 업데이트 — 기존 로직 유지 @@ -516,6 +535,25 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response) AND COALESCE(location_code, '') = COALESCE($5, '')`, [inQty, companyCode, itemCode, whCode || '', locCode || ''] ); + + // 입고취소 이력 기록 + const afterStockRes = await client.query( + `SELECT current_qty FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + const afterQty = afterStockRes.rows[0]?.current_qty || '0'; + await client.query( + `INSERT INTO inventory_history ( + id, company_code, item_number, warehouse_code, location_code, + history_type, history_date, change_qty, after_qty, reason, + created_by, created_date + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '입고취소', NOW()::date, $5, $6, '입고 삭제에 의한 롤백', $7, NOW())`, + [companyCode, itemCode, whCode, locCode, String(-inQty), afterQty, userId] + ); } // 구매입고 발주 롤백: purchase_order_mng 기반 diff --git a/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx index 5bbb5a7a..2a2b622e 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx @@ -96,7 +96,7 @@ const TAB_CONFIGS: TabConfig[] = [ columns: [ { key: "carrier_code", label: "업체코드", width: "120px" }, { key: "carrier_name", label: "업체명", width: "160px" }, - { key: "carrier_type", label: "유형", width: "100px" }, + { key: "carrier_type", label: "업체유형", width: "100px" }, { key: "contact_person", label: "담당자", width: "100px" }, { key: "contact_phone", label: "연락처", width: "130px" }, { key: "email", label: "이메일", width: "180px" }, @@ -107,12 +107,12 @@ const TAB_CONFIGS: TabConfig[] = [ formFields: [ { key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" }, { key: "carrier_name", label: "업체명", type: "text", required: true, placeholder: "업체명을 입력해주세요" }, - { key: "carrier_type", label: "유형", type: "select", categoryKey: "carrier_mng:carrier_type", placeholder: "유형을 선택해주세요" }, + { key: "carrier_type", label: "업체유형", type: "select", required: true, categoryKey: "carrier_mng:carrier_type", placeholder: "업체유형을 선택해주세요" }, { key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명" }, - { key: "contact_phone", label: "연락처", type: "text", placeholder: "010-0000-0000" }, + { key: "contact_phone", label: "연락처", type: "text", placeholder: "예: 02-1234-5678" }, { key: "email", label: "이메일", type: "text", placeholder: "email@example.com" }, { key: "address", label: "주소", type: "text", placeholder: "주소를 입력해주세요" }, - { key: "rating", label: "등급", type: "select", options: [1, 2, 3, 4, 5].map((v) => ({ value: String(v), label: `${v}등급` })) }, + { key: "rating", label: "등급", type: "select", categoryKey: "carrier_mng:rating", placeholder: "등급을 선택해주세요" }, { key: "status", label: "상태", type: "select", categoryKey: "carrier_mng:status", placeholder: "상태를 선택해주세요" }, ], }, @@ -155,6 +155,7 @@ const TAB_CONFIGS: TabConfig[] = [ { key: "contract_start_date", label: "시작일", width: "110px" }, { key: "contract_end_date", label: "종료일", width: "110px" }, { key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true }, + { key: "contact_person", label: "담당자", width: "100px" }, { key: "status", label: "상태", width: "80px", align: "center" }, ], formFields: [ @@ -163,6 +164,7 @@ const TAB_CONFIGS: TabConfig[] = [ { key: "contract_start_date", label: "시작일", type: "date", required: true }, { key: "contract_end_date", label: "종료일", type: "date", required: true }, { key: "contract_amount", label: "계약금액", type: "number", placeholder: "0" }, + { key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명을 입력해주세요" }, { key: "status", label: "상태", type: "select", categoryKey: "carrier_contract_mng:status", placeholder: "상태를 선택해주세요" }, ], }, @@ -184,9 +186,8 @@ const TAB_CONFIGS: TabConfig[] = [ ], formFields: [ { key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" }, - { key: "route_name", label: "구간명", type: "text", required: true, placeholder: "구간명을 입력해주세요" }, - { key: "departure", label: "출발지", type: "text", placeholder: "출발지" }, - { key: "destination", label: "도착지", type: "text", placeholder: "도착지" }, + { key: "departure", label: "출발지", type: "text", required: true, placeholder: "출발지" }, + { key: "destination", label: "도착지", type: "text", required: true, placeholder: "도착지" }, { key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" }, { key: "avg_time_hours", label: "평균시간(h)", type: "number", placeholder: "0" }, { key: "route_type", label: "구간유형", type: "select", categoryKey: "delivery_route_mng:route_type", placeholder: "유형을 선택해주세요" }, @@ -202,19 +203,21 @@ const TAB_CONFIGS: TabConfig[] = [ columns: [ { key: "vehicle_code", label: "차량코드", width: "120px" }, { key: "vehicle_number", label: "차량번호", width: "120px" }, - { key: "vehicle_type", label: "차종", width: "100px" }, + { key: "vehicle_type", label: "차량유형", width: "100px" }, { key: "carrier_code", label: "운송업체", width: "120px" }, { key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true }, { key: "driver_name", label: "운전자", width: "100px" }, + { key: "last_maintenance_date", label: "최종정비일", width: "110px" }, { key: "status", label: "상태", width: "80px", align: "center" }, ], formFields: [ { key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" }, { key: "vehicle_number", label: "차량번호", type: "text", required: true, placeholder: "12가 3456" }, - { key: "vehicle_type", label: "차종", type: "select", categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차종을 선택해주세요" }, + { key: "vehicle_type", label: "차량유형", type: "select", required: true, categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차량유형을 선택해주세요" }, { key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" }, { key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" }, { key: "driver_name", label: "운전자", type: "text", placeholder: "운전자명" }, + { key: "last_maintenance_date", label: "최종정비일", type: "date" }, { key: "status", label: "상태", type: "select", categoryKey: "carrier_vehicle_mng:status", placeholder: "상태를 선택해주세요" }, ], }, @@ -433,16 +436,22 @@ export default function LogisticsInfoPage() { } try { + // 배송구간: 출발지→도착지 로 구간명 자동 생성 + const saveData = { ...formData }; + if (activeTab === "route" && saveData.departure && saveData.destination) { + saveData.route_name = `${saveData.departure}→${saveData.destination}`; + } + if (editMode && editId) { await apiClient.put(`/table-management/tables/${config.tableName}/edit`, { originalData: { id: editId }, - updatedData: formData, + updatedData: saveData, }); toast.success("수정이 완료되었어요."); } else { await apiClient.post( `/table-management/tables/${config.tableName}/add`, - { id: crypto.randomUUID(), ...formData } + { id: crypto.randomUUID(), ...saveData } ); toast.success("등록이 완료되었어요."); } diff --git a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx index df51c4d6..0bef1bd7 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx @@ -18,7 +18,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; @@ -43,6 +43,7 @@ import { Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { toast } from "sonner"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -50,6 +51,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { getOutboundList, createOutbound, + updateOutbound, deleteOutbound, generateOutboundNumber, getOutboundWarehouses, @@ -146,6 +148,10 @@ export default function OutboundPage() { const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + // 수정 모드 (등록 모달을 재활용) + const [editMode, setEditMode] = useState(false); + const [editItemIds, setEditItemIds] = useState([]); + // 소스 데이터 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceLoading, setSourceLoading] = useState(false); @@ -249,6 +255,8 @@ export default function OutboundPage() { const openRegisterModal = async () => { const defaultType = "판매출고"; + setEditMode(false); + setEditItemIds([]); setModalOutboundType(defaultType); setModalOutboundDate(new Date().toISOString().split("T")[0]); setModalWarehouse(""); @@ -273,6 +281,43 @@ export default function OutboundPage() { } }; + // 수정 모달 열기 (같은 출고번호 묶어서) + const openEditModal = (row: OutboundItem) => { + const outNo = row.outbound_number; + const grouped = data.filter((d) => d.outbound_number === outNo); + const first = grouped[0] || row; + + setEditMode(true); + setEditItemIds(grouped.map((g) => g.id)); + setModalOutboundNo(outNo); + setModalOutboundType(first.outbound_type || "판매출고"); + setModalOutboundDate(first.outbound_date ? first.outbound_date.slice(0, 10) : ""); + setModalWarehouse(first.warehouse_code || ""); + setModalLocation(first.location_code || ""); + setModalManager(first.manager_id || ""); + setModalMemo(first.memo || ""); + setSelectedItems( + grouped.map((g) => ({ + key: g.id, + outbound_type: g.outbound_type || "", + reference_number: g.reference_number || "", + customer_code: g.customer_code || "", + customer_name: g.customer_name || "", + item_number: g.item_code || "", + item_name: g.item_name || "", + spec: g.specification || "", + material: g.material || "", + unit: g.unit || "", + outbound_qty: Number(g.outbound_qty) || 0, + unit_price: Number(g.unit_price) || 0, + total_amount: Number(g.total_amount) || 0, + source_type: g.source_type || "", + source_id: g.source_id || "", + })) + ); + setIsModalOpen(true); + }; + const searchSourceData = useCallback(async () => { setSourcePage(1); await loadSourceData(modalOutboundType, sourceKeyword || undefined); @@ -445,44 +490,67 @@ export default function OutboundPage() { setSaving(true); try { - const res = await createOutbound({ - outbound_number: modalOutboundNo, - outbound_date: modalOutboundDate, - warehouse_code: modalWarehouse || undefined, - location_code: modalLocation || undefined, - manager_id: modalManager || undefined, - memo: modalMemo || undefined, - items: selectedItems.map((item) => ({ - outbound_type: item.outbound_type, - reference_number: item.reference_number, - customer_code: item.customer_code, - customer_name: item.customer_name, - item_code: item.item_number, - item_name: item.item_name, - spec: item.spec, - material: item.material, - unit: item.unit, - outbound_qty: item.outbound_qty, - unit_price: item.unit_price, - total_amount: item.total_amount, - source_type: item.source_type, - source_id: item.source_id, - outbound_status: "출고완료", - })), - }); - - if (res.success) { - alert(res.message || "출고 등록 완료"); + if (editMode) { + // 수정 모드: 각 아이템별 update + await Promise.all( + selectedItems.map((item) => + updateOutbound(item.key, { + outbound_date: modalOutboundDate, + outbound_qty: item.outbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + manager_id: modalManager || undefined, + memo: modalMemo || undefined, + } as any) + ) + ); + toast.success("출고 정보를 수정했어요"); setIsModalOpen(false); fetchList(); + } else { + const res = await createOutbound({ + outbound_number: modalOutboundNo, + outbound_date: modalOutboundDate, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + manager_id: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + outbound_type: item.outbound_type, + reference_number: item.reference_number, + customer_code: item.customer_code, + customer_name: item.customer_name, + item_code: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + outbound_qty: item.outbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_type: item.source_type, + source_id: item.source_id, + outbound_status: "출고완료", + })), + }); + + if (res.success) { + toast.success(res.message || "출고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } } } catch { - alert("출고 등록 중 오류가 발생했습니다."); + toast.error(editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다."); } finally { setSaving(false); } }; + + // 합계 계산 const totalSummary = useMemo(() => { return { @@ -593,6 +661,7 @@ export default function OutboundPage() { checkedIds.includes(row.id) && "bg-primary/5" )} onClick={() => toggleCheck(row.id)} + onDoubleClick={() => openEditModal(row)} > - 출고 등록 - 출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요. + {editMode ? "출고 수정" : "출고 등록"} + {editMode ? "출고 정보를 수정해주세요." : "출고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가해주세요."} - {/* 출고유형 선택 */} -
+ {/* 출고유형 선택 (수정 모드에서는 숨김) */} + {!editMode &&
출고유형 )}
- + } - e.stopPropagation()} /> + {!editMode && e.stopPropagation()} />} {/* 우측: 출고 정보 + 선택 품목 */} - +

출고 정보

@@ -906,7 +975,7 @@ export default function OutboundPage() { 수량 단가 금액 - + {!editMode && } @@ -946,7 +1015,7 @@ export default function OutboundPage() { {item.total_amount.toLocaleString()} - + {!editMode && - + } ))} diff --git a/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx index 8e59c67b..d8e0c3e1 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx @@ -43,6 +43,7 @@ import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Plus, Trash2, @@ -51,6 +52,9 @@ import { MapPin, Building2, Settings2, + Layers, + Info, + Eye, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -135,6 +139,18 @@ export default function WarehouseManagementPage() { const [locationForm, setLocationForm] = useState>({}); const [locationSaving, setLocationSaving] = useState(false); + // 모달: 랙 구조 일괄 등록 + const [rackModalOpen, setRackModalOpen] = useState(false); + const [rackFloor, setRackFloor] = useState(""); + const [rackZone, setRackZone] = useState(""); + const [rackConditions, setRackConditions] = useState< + { id: string; startRow: number; endRow: number; levels: number }[] + >([]); + const [rackLocationType, setRackLocationType] = useState(""); + const [rackStatus, setRackStatus] = useState(""); + const [rackPreview, setRackPreview] = useState([]); + const [rackSaving, setRackSaving] = useState(false); + // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState< Record @@ -169,7 +185,7 @@ export default function WarehouseManagementPage() { setCategoryOptions(whOpts); const locOpts: Record = {}; - for (const col of ["location_type", "status"]) { + for (const col of ["location_type", "status", "floor", "zone"]) { try { const res = await apiClient.get( `/table-categories/${LOCATION_TABLE}/${col}/values` @@ -461,6 +477,134 @@ export default function WarehouseManagementPage() { } }; + // ─── 랙 구조 일괄 등록 ─── + + const openRackModal = () => { + setRackFloor(""); + setRackZone(""); + setRackConditions([]); + setRackLocationType(""); + setRackStatus(""); + setRackPreview([]); + setRackSaving(false); + setRackModalOpen(true); + }; + + const addRackCondition = () => { + const lastEnd = rackConditions.length > 0 + ? rackConditions[rackConditions.length - 1].endRow + : 0; + setRackConditions((prev) => [ + ...prev, + { id: crypto.randomUUID(), startRow: lastEnd + 1, endRow: lastEnd + 1, levels: 1 }, + ]); + }; + + const updateRackCondition = (id: string, field: string, value: number) => { + setRackConditions((prev) => + prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)) + ); + }; + + const removeRackCondition = (id: string) => { + setRackConditions((prev) => prev.filter((c) => c.id !== id)); + }; + + const generateRackPreview = () => { + if (!rackFloor.trim() || !rackZone.trim()) { + toast.error("층과 구역을 입력해주세요"); + return; + } + if (rackConditions.length === 0) { + toast.error("조건을 1개 이상 추가해주세요"); + return; + } + // 조건 유효성 검사 + for (const cond of rackConditions) { + if (cond.startRow < 1 || cond.endRow < 1 || cond.levels < 1) { + toast.error("열 범위와 단 수는 1 이상이어야 합니다"); + return; + } + if (cond.endRow < cond.startRow) { + toast.error("끝 열은 시작 열보다 크거나 같아야 합니다"); + return; + } + } + + const whCode = selectedWarehouse?.warehouse_code || ""; + // 카테고리 코드→라벨 변환 (셀렉트에서 코드가 저장되므로) + const floorOpts = locationCategoryOptions["floor"] || []; + const zoneOpts = locationCategoryOptions["zone"] || []; + const floorLabel = floorOpts.find(o => o.code === rackFloor)?.label || rackFloor.trim(); + const zoneLabel = zoneOpts.find(o => o.code === rackZone)?.label || rackZone.trim(); + const floorCode = floorLabel.replace(/층$/, ""); + const zoneCode = zoneLabel.replace(/구역$/, ""); + + const items: any[] = []; + for (const cond of rackConditions) { + for (let row = cond.startRow; row <= cond.endRow; row++) { + for (let level = 1; level <= cond.levels; level++) { + const rowStr = String(row).padStart(2, "0"); + const locationCode = `${whCode}-${floorCode}${zoneCode}-${rowStr}-${level}`; + const locationName = `${zoneCode}구역-${rowStr}열-${level}단`; + items.push({ + location_code: locationCode, + location_name: locationName, + warehouse_code: whCode, + floor: floorLabel, + zone: zoneLabel, + row_num: String(row), + level_num: String(level), + location_type: rackLocationType, + status: rackStatus, + }); + } + } + } + setRackPreview(items); + }; + + const handleRackBulkSave = async () => { + if (rackPreview.length === 0) { + toast.error("미리보기를 먼저 생성해주세요"); + return; + } + setRackSaving(true); + try { + let successCount = 0; + for (const item of rackPreview) { + await apiClient.post( + `/table-management/tables/${LOCATION_TABLE}/add`, + { id: crypto.randomUUID(), ...item } + ); + successCount++; + } + toast.success(`${successCount}개의 위치가 등록되었어요`); + setRackModalOpen(false); + fetchLocations(); + } catch (err) { + toast.error("일괄 등록 중 오류가 발생했어요"); + } finally { + setRackSaving(false); + } + }; + + // 랙 조건별 통계 + const getRackConditionCount = (cond: { startRow: number; endRow: number; levels: number }) => { + if (cond.endRow < cond.startRow || cond.startRow < 1 || cond.levels < 1) return 0; + return (cond.endRow - cond.startRow + 1) * cond.levels; + }; + + // 랙 미리보기 통계 + const rackStats = { + totalLocations: rackPreview.length, + totalRows: rackConditions.reduce((acc, c) => { + if (c.endRow >= c.startRow && c.startRow >= 1) return acc + (c.endRow - c.startRow + 1); + return acc; + }, 0), + maxLevels: rackConditions.reduce((acc, c) => Math.max(acc, c.levels || 0), 0), + }; + // 엑셀 내보내기 const handleExcelExport = () => { if (warehouses.length === 0) { @@ -655,6 +799,15 @@ export default function WarehouseManagementPage() { 위치 등록 + +
+ + {rackConditions.length === 0 ? ( +
+ +

+ 조건을 추가하여 랙 구조를 설정하세요 +

+ +
+ ) : ( +
+ {rackConditions.map((cond, idx) => ( +
+ + 조건{idx + 1} + +
+ + updateRackCondition(cond.id, "startRow", Number(e.target.value) || 0) + } + placeholder="시작" + /> + ~ + + updateRackCondition(cond.id, "endRow", Number(e.target.value) || 0) + } + placeholder="끝" + /> + 열, + + updateRackCondition(cond.id, "levels", Number(e.target.value) || 0) + } + placeholder="단" + /> + +
+ {getRackConditionCount(cond) > 0 && ( + + {cond.startRow}열 ~ {cond.endRow}열 × {cond.levels}단 = {getRackConditionCount(cond)}개 + + )} + +
+ ))} +
+ )} +
+ + {/* 공통 설정 */} +
+

+ ⚙️ 공통 설정 +

+
+
+ + +
+
+ + +
+
+
+ + {/* 등록 미리보기 */} +
+
+

+ 👁️ 등록 미리보기 +

+ +
+ + {rackPreview.length > 0 && ( + <> + {/* 통계 카드 */} +
+
+

총 위치

+

{rackStats.totalLocations}개

+
+
+

열 수

+

{rackStats.totalRows}개

+
+
+

최대 단

+

{rackStats.maxLevels}

+
+
+ + {/* 미리보기 테이블 */} +
+ + + + No + 위치코드 + 위치명 + + 구역 + + + 유형 + 비고 + + + + {rackPreview.map((item, idx) => ( + + + {idx + 1} + + {item.location_code} + {item.location_name} + {item.floor} + {item.zone} + {item.row_num} + {item.level_num} + + {resolveCategory(locationCategoryOptions, "location_type", item.location_type) || "-"} + + - + + ))} + +
+
+ + )} + + {rackPreview.length === 0 && ( +
+ 위의 설정을 완료한 뒤 미리보기 생성 버튼을 클릭하세요 +
+ )} +
+
+ + + + + + +
+ + >({}); + const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); /* ═══════════════════ 카테고리 로드 ═══════════════════ */ useEffect(() => { @@ -98,7 +101,14 @@ export default function InspectionManagementPage() { const catList = [ { table: INSPECTION_TABLE, col: "inspection_type" }, { table: INSPECTION_TABLE, col: "apply_type" }, + { table: INSPECTION_TABLE, col: "inspection_method" }, + { table: INSPECTION_TABLE, col: "judgment_criteria" }, + { table: INSPECTION_TABLE, col: "unit" }, { table: DEFECT_TABLE, col: "defect_type" }, + { table: DEFECT_TABLE, col: "severity" }, + { table: DEFECT_TABLE, col: "inspection_type" }, + { table: DEFECT_TABLE, col: "is_active" }, + { table: EQUIPMENT_TABLE, col: "equipment_type" }, { table: EQUIPMENT_TABLE, col: "equipment_status" }, ]; await Promise.all( @@ -112,21 +122,44 @@ export default function InspectionManagementPage() { }) ); setCatOptions(optMap); + // 사용자 목록 로드 + try { + const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { + page: 1, size: 500, autoFilter: true, + }); + const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; + setUserOptions(users.map((u: any) => ({ + code: u.user_id || u.id, + label: `${u.user_name || u.name || u.user_id}${u.dept_name ? ` (${u.dept_name})` : ""}`, + }))); + } catch { /* skip */ } }; load(); }, []); const getCatLabel = (table: string, col: string, code: string) => { + if (!code) return ""; const opts = catOptions[`${table}.${col}`]; if (!opts) return code; + // 쉼표 구분 다중 코드 지원 + if (code.includes(",")) { + return code.split(",").filter(Boolean).map(c => opts.find(o => o.code === c)?.label || c).join(", "); + } return opts.find(o => o.code === code)?.label || code; }; /* ═══════════════════ 데이터 조회 ═══════════════════ */ + // 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용 + const MULTI_VALUE_COLUMNS = ["inspection_type"]; + const fetchInspections = useCallback(async () => { setInspLoading(true); try { - const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const filters = searchFilters.map((f) => ({ + columnName: f.columnName, + operator: MULTI_VALUE_COLUMNS.includes(f.columnName) ? "contains" : f.operator, + value: f.value, + })); const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -190,7 +223,11 @@ export default function InspectionManagementPage() { const openInspCreate = () => { setInspForm({}); setInspEditMode(false); setInspModalOpen(true); }; const openInspEdit = (row: any) => { setInspForm({ ...row }); setInspEditMode(true); setInspModalOpen(true); }; const saveInspection = async () => { - if (!inspForm.inspection_standard) { toast.error("검사기준은 필수 입력이에요"); return; } + if (!inspForm.inspection_code) { toast.error("검사코드는 필수예요"); return; } + if (!inspForm.inspection_type) { toast.error("유형을 1개 이상 선택해주세요"); return; } + if (!inspForm.inspection_criteria) { toast.error("검사기준은 필수예요"); return; } + if (!inspForm.inspection_item) { toast.error("검사항목은 필수예요"); return; } + if (!inspForm.judgment_criteria) { toast.error("판단기준은 필수예요"); return; } setInspSaving(true); try { if (inspEditMode) { @@ -225,7 +262,12 @@ export default function InspectionManagementPage() { const openDefCreate = () => { setDefForm({}); setDefEditMode(false); setDefModalOpen(true); }; const openDefEdit = (row: any) => { setDefForm({ ...row }); setDefEditMode(true); setDefModalOpen(true); }; const saveDefect = async () => { - if (!defForm.defect_name) { toast.error("불량명은 필수 입력이에요"); return; } + if (!defForm.defect_code) { toast.error("불량코드는 필수예요"); return; } + if (!defForm.defect_type) { toast.error("불량유형은 필수예요"); return; } + if (!defForm.defect_name) { toast.error("불량명은 필수예요"); return; } + if (!defForm.severity) { toast.error("심각도는 필수예요"); return; } + if (!defForm.defect_content) { toast.error("불량내용은 필수예요"); return; } + if (!defForm.inspection_type) { toast.error("검사유형을 1개 이상 선택해주세요"); return; } setDefSaving(true); try { if (defEditMode) { @@ -257,10 +299,21 @@ export default function InspectionManagementPage() { }; /* ═══════════════════ 검사장비 CRUD ═══════════════════ */ - const openEqCreate = () => { setEqForm({}); setEqEditMode(false); setEqModalOpen(true); }; + const openEqCreate = () => { + const maxNum = equipments + .map((e: any) => e.equipment_code || "") + .filter((c: string) => /^EQP-\d+$/.test(c)) + .map((c: string) => parseInt(c.replace("EQP-", ""), 10)) + .sort((a: number, b: number) => b - a)[0] || 0; + setEqForm({ equipment_code: `EQP-${String(maxNum + 1).padStart(3, "0")}`, calibration_period: "12", equipment_status: "NORMAL" }); + setEqEditMode(false); + setEqModalOpen(true); + }; const openEqEdit = (row: any) => { setEqForm({ ...row }); setEqEditMode(true); setEqModalOpen(true); }; const saveEquipment = async () => { - if (!eqForm.equipment_name) { toast.error("장비명은 필수 입력이에요"); return; } + if (!eqForm.equipment_code) { toast.error("장비코드는 필수예요"); return; } + if (!eqForm.equipment_name) { toast.error("장비명은 필수예요"); return; } + if (!eqForm.equipment_type) { toast.error("장비유형은 필수예요"); return; } setEqSaving(true); try { if (eqEditMode) { @@ -384,8 +437,10 @@ export default function InspectionManagementPage() {
{ts.visibleColumns.map((col) => { if (col.key === "inspection_type") return {getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)}; + if (col.key === "inspection_method") return {getCatLabel(INSPECTION_TABLE, "inspection_method", row.inspection_method)}; + if (col.key === "judgment_criteria") return {getCatLabel(INSPECTION_TABLE, "judgment_criteria", row.judgment_criteria)}; + if (col.key === "unit") return {getCatLabel(INSPECTION_TABLE, "unit", row.unit)}; if (col.key === "apply_type") return {getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)}; - if (col.key === "is_active") return {row.is_active ? "사용" : "미사용"}; return {row[col.key] ?? ""}; })} @@ -430,37 +485,63 @@ export default function InspectionManagementPage() { onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map(r => r.id) : [])} /> - 불량유형 + 불량코드 + 불량유형 불량명 - 심각도 - 사용여부 + 불량내용 + 심각도 + 검사유형 + 적용대상 + 사용여부 + 등록일 + 관리자 + 비고 {defLoading ? ( - + ) : filteredDefects.length === 0 ? ( -

등록된 불량유형이 없어요

- ) : filteredDefects.map((row) => ( - setDefChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} - onDoubleClick={() => openDefEdit(row)} - > - e.stopPropagation()}> - setDefChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> - - {getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)} - {row.defect_name} - - {row.severity} - - - {row.is_active ? "사용" : "미사용"} - - - ))} +

등록된 불량유형이 없어요

+ ) : filteredDefects.map((row) => { + const severityLabel = getCatLabel(DEFECT_TABLE, "severity", row.severity); + const severityColor = severityLabel === "치명적" ? "destructive" : severityLabel === "심각" ? "destructive" : severityLabel === "보통" ? "secondary" : "outline"; + return ( + setDefChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} + onDoubleClick={() => openDefEdit(row)} + > + e.stopPropagation()}> + setDefChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> + + {row.defect_code || "-"} + {getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)} + {row.defect_name || "-"} + {row.defect_content || "-"} + {severityLabel || "-"} + +
+ {row.inspection_type ? row.inspection_type.split(",").filter(Boolean).map((c: string) => ( + {getCatLabel(DEFECT_TABLE, "inspection_type", c)} + )) : "-"} +
+
+ +
+ {row.apply_target ? row.apply_target.split(",").filter(Boolean).map((t: string) => ( + {t} + )) : "-"} +
+
+ {getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || "-"} + {row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")} + {row.manager_id || "-"} + {row.remarks || "-"} +
+ ); + })}
@@ -501,37 +582,49 @@ export default function InspectionManagementPage() { onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map(r => r.id) : [])} /> + 장비코드 장비명 - 모델명 - 제조사 - 교정주기 - 최종교정일 - 장비상태 + 장비유형 + 모델명 + 제조사 + 설치장소 + 최근교정일 + 교정주기(개월) + 장비상태 + 담당자 {eqLoading ? ( - + ) : filteredEquipments.length === 0 ? ( -

등록된 검사장비가 없어요

- ) : filteredEquipments.map((row) => ( - setEqChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} - onDoubleClick={() => openEqEdit(row)} - > - e.stopPropagation()}> - setEqChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> - - {row.equipment_name} - {row.model_name} - {row.manufacturer} - {row.calibration_cycle} - {row.last_calibration_date} - {getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status)} - - ))} +

등록된 검사장비가 없어요

+ ) : filteredEquipments.map((row) => { + const statusLabel = getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status); + const statusColor = statusLabel === "정상" ? "default" : statusLabel === "폐기" ? "destructive" : "secondary"; + return ( + setEqChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} + onDoubleClick={() => openEqEdit(row)} + > + e.stopPropagation()}> + setEqChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> + + {row.equipment_code || "-"} + {row.equipment_name || "-"} + {getCatLabel(EQUIPMENT_TABLE, "equipment_type", row.equipment_type) || "-"} + {row.model_name || "-"} + {row.manufacturer || "-"} + {row.installation_location || "-"} + {row.last_calibration_date || "-"} + {row.calibration_period ? `${row.calibration_period}개월` : "-"} + {statusLabel || "-"} + {userOptions.find(u => u.code === row.manager_id)?.label || row.manager_id || "-"} + + ); + })}
@@ -541,62 +634,136 @@ export default function InspectionManagementPage() { {/* ═══════════════════ 검사기준 모달 ═══════════════════ */} - + {inspEditMode ? "검사기준 수정" : "검사기준 등록"} 검사기준 정보를 입력해주세요 -
+
+ {/* 검사코드 */}
- - setInspForm(p => ({ ...p, inspection_code: e.target.value }))} placeholder="검사코드 입력" /> +
+ {/* 유형 (다중선택) */} +
+ +
+ {(catOptions[`${INSPECTION_TABLE}.inspection_type`] || []).map(o => { + const types: string[] = inspForm.inspection_type ? inspForm.inspection_type.split(",").filter(Boolean) : []; + const checked = types.includes(o.code); + return ( +
+ { + const next = v ? [...types, o.code] : types.filter(t => t !== o.code); + setInspForm(p => ({ ...p, inspection_type: next.join(",") })); + }} + /> + +
+ ); + })} +
+
+ {/* 검사기준 */} +
+ + setInspForm(p => ({ ...p, inspection_criteria: e.target.value }))} placeholder="검사기준 입력" /> +
+ {/* 기준상세 */} +
+ + setInspForm(p => ({ ...p, criteria_detail: e.target.value }))} placeholder="기준상세 입력" /> +
+ {/* 검사항목 */} +
+ + setInspForm(p => ({ ...p, inspection_item: e.target.value }))} placeholder="검사항목 입력" /> +
+ {/* 검사방법 */} +
+ +
+ {/* 판단기준 */}
- - setInspForm(p => ({ ...p, judgment_criteria: v === "__none__" ? "" : v }))}> + + 선택 안함 + {(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).map(o => ( + {o.label} + ))} + + +
+ {/* 단위 */} +
+ + +
+ {/* 적용구분 */} +
+ +
-
- - setInspForm(p => ({ ...p, inspection_standard: e.target.value }))} placeholder="검사기준을 입력해주세요" /> -
-
- - setInspForm(p => ({ ...p, inspection_item_name: e.target.value }))} placeholder="검사항목명을 입력해주세요" /> -
+ {/* 관리자 */}
- - setInspForm(p => ({ ...p, inspection_method: e.target.value }))} placeholder="검사방법" /> -
-
- - setInspForm(p => ({ ...p, unit: e.target.value }))} placeholder="단위" /> + +
+ {/* 비고 */}
-
- setInspForm(p => ({ ...p, is_active: !!v }))} /> - -
+ +