From eee20c6581d8c5cd3cc109dca1b3e74d5cd84d0d Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 3 Apr 2026 16:02:14 +0900 Subject: [PATCH] refactor: Update logistics and inspection pages for improved field labels and validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed field labels for clarity in logistics info page, updating "유형" to "업체유형" and enhancing placeholders for better user guidance. - Added required validation for fields in both logistics and inspection forms, ensuring essential data is captured before submission. - Introduced dynamic generation of route names based on departure and destination inputs in the logistics info page. - Enhanced inspection management page with additional user options and improved handling of inspection types and criteria. These changes aim to enhance user experience and data integrity across logistics and inspection functionalities. --- .../src/controllers/outboundController.ts | 23 +- .../src/controllers/receivingController.ts | 38 ++ .../(main)/COMPANY_16/logistics/info/page.tsx | 31 +- .../COMPANY_16/logistics/outbound/page.tsx | 155 +++-- .../COMPANY_16/logistics/warehouse/page.tsx | 493 ++++++++++++++- .../COMPANY_16/quality/inspection/page.tsx | 581 ++++++++++++++---- .../quality/item-inspection/page.tsx | 445 +++++++++++--- 7 files changed, 1513 insertions(+), 253 deletions(-) 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 }))} /> - -
+ +