From 4d55aebe6104b39d23ec0be021d950f6ddb7a842 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 14 Apr 2026 16:45:12 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=AC=B4=EC=A3=BC=EC=84=9C=EC=BF=A0?= =?UTF-8?q?=EC=9D=98=EC=9B=90=EC=9E=A5=EB=8B=98=20=EC=82=B4=EB=A0=A4?= =?UTF-8?q?=EC=A3=BC=EC=8B=AD=EC=87=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/receivingController.ts | 61 ++++++--- .../(main)/COMPANY_16/equipment/info/page.tsx | 125 ++++++++++++++++-- .../COMPANY_16/logistics/receiving/page.tsx | 10 +- frontend/components/common/EDataTable.tsx | 4 +- 4 files changed, 167 insertions(+), 33 deletions(-) diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index c57cd7c0..f6a818f0 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -65,6 +65,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + // 같은 (company_code, inbound_number) 헤더가 N건일 때 1건만 사용 (중복 헤더 가드) const query = ` SELECT im.id, im.company_code, im.inbound_number, im.inbound_type, im.inbound_date, @@ -90,7 +91,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) { id.seq_no, id.inbound_type AS detail_inbound_type, wh.warehouse_name - FROM inbound_mng im + FROM ( + SELECT DISTINCT ON (h.company_code, h.inbound_number) h.* + FROM inbound_mng h + ORDER BY h.company_code, h.inbound_number, h.created_date NULLS LAST + ) im LEFT JOIN inbound_detail id ON id.inbound_id = im.inbound_number AND id.company_code = im.company_code LEFT JOIN warehouse_info wh @@ -146,9 +151,25 @@ export async function create(req: AuthenticatedRequest, res: Response) { await client.query("BEGIN"); - // 1. 헤더 INSERT (inbound_mng) — 품목 컬럼은 NULL - const headerResult = await client.query( - `INSERT INTO inbound_mng ( + // 1. 헤더 — 같은 (company_code, inbound_number) 헤더가 있으면 reuse, 없으면 INSERT (멱등성) + let headerRow: any; + const existingHeader = await client.query( + `SELECT * FROM inbound_mng + WHERE company_code = $1 AND inbound_number = $2 + ORDER BY created_date NULLS LAST + LIMIT 1`, + [companyCode, inboundNumber], + ); + if (existingHeader.rows.length > 0) { + headerRow = existingHeader.rows[0]; + logger.info("입고 헤더 reuse (멱등)", { + companyCode, + inboundNumber, + headerId: headerRow.id, + }); + } else { + const headerResult = await client.query( + `INSERT INTO inbound_mng ( id, company_code, inbound_number, inbound_type, inbound_date, warehouse_code, location_code, inbound_status, inspector, manager, memo, @@ -159,22 +180,22 @@ export async function create(req: AuthenticatedRequest, res: Response) { $7, $8, $9, $10, NOW(), $11, $11, '입고' ) RETURNING *`, - [ - companyCode, - inboundNumber, - inboundType, - inbound_date || items[0].inbound_date, - warehouse_code || items[0].warehouse_code || null, - location_code || items[0].location_code || null, - items[0].inbound_status || "대기", - inspector || items[0].inspector || null, - manager || items[0].manager || null, - memo || items[0].memo || null, - userId, - ], - ); - - const headerRow = headerResult.rows[0]; + [ + companyCode, + inboundNumber, + inboundType, + inbound_date || items[0].inbound_date, + warehouse_code || items[0].warehouse_code || null, + location_code || items[0].location_code || null, + items[0].inbound_status || "대기", + inspector || items[0].inspector || null, + manager || items[0].manager || null, + memo || items[0].memo || null, + userId, + ], + ); + headerRow = headerResult.rows[0]; + } const insertedDetails: any[] = []; // 2. 디테일 INSERT (inbound_detail) + 재고/발주 업데이트 diff --git a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx index cad20e4d..e08ad1a9 100644 --- a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx @@ -59,6 +59,7 @@ export default function EquipmentInfoPage() { const [equipCount, setEquipCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [selectedEquipId, setSelectedEquipId] = useState(null); + const [checkedEquipIds, setCheckedEquipIds] = useState([]); // 우측 탭 const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info"); @@ -86,6 +87,7 @@ export default function EquipmentInfoPage() { const [inspectionForm, setInspectionForm] = useState>({}); const [inspectionContinuous, setInspectionContinuous] = useState(false); const [inspectionEditMode, setInspectionEditMode] = useState(false); + const [checkedInspectionIds, setCheckedInspectionIds] = useState>(new Set()); // 소모품 추가/수정 모달 const [consumableModalOpen, setConsumableModalOpen] = useState(false); @@ -93,6 +95,7 @@ export default function EquipmentInfoPage() { const [consumableContinuous, setConsumableContinuous] = useState(false); const [consumableEditMode, setConsumableEditMode] = useState(false); const [consumableItemOptions, setConsumableItemOptions] = useState([]); + const [checkedConsumableIds, setCheckedConsumableIds] = useState>(new Set()); // 점검항목 복사 const [copyModalOpen, setCopyModalOpen] = useState(false); @@ -204,6 +207,7 @@ export default function EquipmentInfoPage() { // 우측: 점검항목 조회 useEffect(() => { + setCheckedInspectionIds(new Set()); if (!selectedEquip?.equipment_code) { setInspections([]); return; } const fetchData = async () => { setInspectionLoading(true); @@ -221,6 +225,7 @@ export default function EquipmentInfoPage() { // 우측: 소모품 조회 useEffect(() => { + setCheckedConsumableIds(new Set()); if (!selectedEquip?.equipment_code) { setConsumables([]); return; } const fetchData = async () => { setConsumableLoading(true); @@ -258,7 +263,15 @@ export default function EquipmentInfoPage() { } } catch { /* 채번 규칙 없으면 수동 입력 */ } }; - const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); }; + const openEquipEdit = () => { + if (checkedEquipIds.length > 1) { toast.error("수정은 한 건만 선택해주세요."); return; } + const targetId = checkedEquipIds[0] || selectedEquipId; + const target = equipments.find((e) => e.id === targetId); + if (!target) { toast.error("수정할 설비를 선택해주세요."); return; } + setEquipForm({ ...target }); + setEquipEditMode(true); + setEquipModalOpen(true); + }; const handleEquipSave = async () => { if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; } @@ -287,12 +300,44 @@ export default function EquipmentInfoPage() { }; const handleEquipDelete = async () => { - if (!selectedEquipId) return; - const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" }); + const targetIds = checkedEquipIds.length > 0 ? checkedEquipIds : (selectedEquipId ? [selectedEquipId] : []); + if (targetIds.length === 0) { toast.error("삭제할 설비를 선택해주세요."); return; } + const ok = await confirm(`선택한 ${targetIds.length}건의 설비를 삭제하시겠습니까?`, { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] }); - toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments(); + await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: targetIds.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedEquipIds([]); + if (selectedEquipId && targetIds.includes(selectedEquipId)) setSelectedEquipId(null); + fetchEquipments(); + } catch { toast.error("삭제 실패"); } + }; + + // 점검항목 삭제 + const handleInspectionDelete = async () => { + const ids = Array.from(checkedInspectionIds); + if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; } + const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedInspectionIds(new Set()); + refreshRight(); + } catch { toast.error("삭제 실패"); } + }; + + // 소모품 삭제 + const handleConsumableDelete = async () => { + const ids = Array.from(checkedConsumableIds); + if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; } + const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedConsumableIds(new Set()); + refreshRight(); } catch { toast.error("삭제 실패"); } }; @@ -502,9 +547,9 @@ export default function EquipmentInfoPage() {
- +
- +
+ )} {rightTab === "consumable" && ( - + <> + + + )}
@@ -637,6 +694,16 @@ export default function EquipmentInfoPage() { + { + const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length; + if (allChecked) setCheckedInspectionIds(new Set()); + else setCheckedInspectionIds(new Set(inspections.map((i) => i.id))); + }} + > + 0 && checkedInspectionIds.size === inspections.length} /> + 점검항목 점검주기 점검방법 @@ -664,6 +731,20 @@ export default function EquipmentInfoPage() { setInspectionEditMode(true); setInspectionModalOpen(true); }}> + { + e.stopPropagation(); + setCheckedInspectionIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + return next; + }); + }} + onDoubleClick={(e) => e.stopPropagation()} + > + + {item.inspection_item || "-"} {resolve("inspection_cycle", item.inspection_cycle)} {resolve("inspection_method", item.inspection_method)} @@ -692,6 +773,16 @@ export default function EquipmentInfoPage() {
+ { + const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length; + if (allChecked) setCheckedConsumableIds(new Set()); + else setCheckedConsumableIds(new Set(consumables.map((i) => i.id))); + }} + > + 0 && checkedConsumableIds.size === consumables.length} /> + 소모품명 교체주기 단위 @@ -707,6 +798,20 @@ export default function EquipmentInfoPage() { loadConsumableItems(); setConsumableModalOpen(true); }}> + { + e.stopPropagation(); + setCheckedConsumableIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + return next; + }); + }} + onDoubleClick={(e) => e.stopPropagation()} + > + + {item.consumable_name || "-"} {item.replacement_cycle || "-"} {item.unit || "-"} diff --git a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx index a9825af5..0ce3b0ec 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -55,6 +55,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { toast } from "sonner"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -283,6 +284,7 @@ export default function ReceivingPage() { const [modalMemo, setModalMemo] = useState(""); const [selectedItems, setSelectedItems] = useState([]); const [saving, setSaving] = useState(false); + const savingLockRef = useRef(false); // 수정 모드 const [editMode, setEditMode] = useState(false); @@ -750,6 +752,7 @@ export default function ReceivingPage() { // 저장 const handleSave = async () => { + if (savingLockRef.current) return; if (selectedItems.length === 0) { alert("입고할 품목을 선택해주세요."); return; @@ -769,6 +772,7 @@ export default function ReceivingPage() { toast.error("창고를 선택해주세요."); return; } + savingLockRef.current = true; setSaving(true); try { if (editMode) { @@ -864,6 +868,7 @@ export default function ReceivingPage() { toast.error(msg); } finally { setSaving(false); + savingLockRef.current = false; } }; @@ -1011,9 +1016,10 @@ export default function ReceivingPage() { ) : ( paginatedRows.map((row) => { const isChecked = checkedIds.includes(row.id); + const rowKey = (row as any).detail_id ? `${row.id}-${(row as any).detail_id}` : row.id; return ( = any> { showCheckbox?: boolean; checkedIds?: string[]; onCheckedChange?: (ids: string[]) => void; + checkboxClickOnly?: boolean; onRowClick?: (row: T, index: number) => void; onRowDoubleClick?: (row: T, index: number) => void; @@ -261,6 +262,7 @@ export function EDataTable = any>({ showCheckbox = false, checkedIds = [], onCheckedChange, + checkboxClickOnly = false, onRowClick, onRowDoubleClick, onCellEdit, @@ -727,7 +729,7 @@ export function EDataTable = any>({ onClick={() => { onSelect?.(id); onRowClick?.(row, pageOffset + rowIdx); - if (showCheckbox && onCheckedChange) { + if (showCheckbox && onCheckedChange && !checkboxClickOnly) { const next = checkedIds.includes(id) ? checkedIds.filter((cid) => cid !== id) : [...checkedIds, id];