From a5e9d131004898ac188ec44d73767270403d4439 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 7 May 2026 13:30:46 +0900 Subject: [PATCH] Implement Outsourcing Purchase Order Management Features - Added `DetailModal` for viewing detailed information of outsourcing purchase orders, including master, process, and material details. - Created `RegistrationModal` for registering and editing outsourcing purchase orders with a tabbed interface for source selection and item mapping. - Introduced `ReleaseRequestModal` for handling material release requests, grouping by subcontractor and allowing batch processing. - Updated the main page to integrate these modals and provide a comprehensive management interface for outsourcing purchase orders. (TASK:ERP-019) --- .../purchase-order/DetailModal.tsx | 201 ++ .../purchase-order/RegistrationModal.tsx | 1798 +++++++++++++++++ .../purchase-order/ReleaseRequestModal.tsx | 275 +++ .../outsourcing/purchase-order/page.tsx | 377 ++++ .../components/layout/AdminPageRenderer.tsx | 47 + 5 files changed, 2698 insertions(+) create mode 100644 frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/DetailModal.tsx create mode 100644 frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/RegistrationModal.tsx create mode 100644 frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/ReleaseRequestModal.tsx create mode 100644 frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/page.tsx diff --git a/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/DetailModal.tsx b/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/DetailModal.tsx new file mode 100644 index 00000000..9240b679 --- /dev/null +++ b/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/DetailModal.tsx @@ -0,0 +1,201 @@ +"use client"; + +/** + * 외주발주 상세 모달 (TASK:ERP-019) + * 단일 외주발주의 마스터+공정+자재 정보를 읽기전용으로 조회. + */ + +import React, { useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, Inbox } from "lucide-react"; +import { toast } from "sonner"; + +import { getOutsourcePurchaseOrder, type OPODetail } from "@/lib/api/outsourcePurchase"; +import { toLocalDateTime } from "@/lib/utils/localDate"; + +interface DetailModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + opoId: string; +} + +const STATUS_BADGE: Record = { + 등록: "bg-blue-100 text-blue-800", + 진행중: "bg-amber-100 text-amber-800", + 완료: "bg-emerald-100 text-emerald-800", + 출고요청: "bg-violet-100 text-violet-800", + 취소: "bg-rose-100 text-rose-800", +}; + +const RELEASE_BADGE: Record = { + 대기: "bg-slate-100 text-slate-700", + 요청: "bg-violet-100 text-violet-700", + 완료: "bg-emerald-100 text-emerald-700", +}; + +export function DetailModal({ open, onOpenChange, opoId }: DetailModalProps) { + const [loading, setLoading] = useState(true); + const [detail, setDetail] = useState(null); + + useEffect(() => { + if (!open || !opoId) return; + let alive = true; + (async () => { + setLoading(true); + try { + const d = await getOutsourcePurchaseOrder(opoId); + if (alive) setDetail(d); + } catch (e: any) { + toast.error(e?.message || "외주발주 상세 조회 실패"); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { + alive = false; + }; + }, [open, opoId]); + + return ( + + + + +
+ 외주발주 상세 + {detail?.order_no && ( + + {detail.order_no} + + )} + {detail?.status && ( + + {detail.status} + + )} +
+
+ 외주발주의 마스터 정보·공정·사급자재를 확인합니다. +
+ + {loading ? ( +
+ +
+ ) : !detail ? ( +
+ + 데이터를 찾을 수 없어요 +
+ ) : ( +
+ {/* 마스터 */} +
+

+ 기본 정보 +

+
+ + + + + + + + + + + + +
+ {detail.memo && ( +
+ 메모: {detail.memo} +
+ )} +
+ + {/* 공정 */} +
+

+ 공정 ({detail.processes?.length || 0}건) +

+ {!detail.processes || detail.processes.length === 0 ? ( +
+ 등록된 공정이 없어요 +
+ ) : ( +
+ {detail.processes.map((proc) => ( +
+
+
+ {proc.seq}. + + {proc.process_name || proc.process_code || "(공정)"} + + {proc.vendor_name ? ( + {proc.vendor_name} + ) : ( + + 미지정 + + )} + {proc.material_needed && ( + + 사급자재 필요 + + )} +
+
+ {proc.materials && proc.materials.length > 0 && ( +
+ {proc.materials.map((mat, idx) => ( +
+
+ {mat.item_code} + {mat.item_name} +
+
+ + {Number(mat.qty || 0).toLocaleString()} {mat.unit || ""} + + + {mat.release_status || "대기"} + +
+
+ ))} +
+ )} +
+ ))} +
+ )} +
+
+ )} +
+
+ ); +} + +function Field({ label, value }: { label: string; value?: string | null }) { + return ( +
+ {label} + {value || "-"} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/RegistrationModal.tsx b/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/RegistrationModal.tsx new file mode 100644 index 00000000..414fe32b --- /dev/null +++ b/frontend/app/(main)/COMPANY_28/outsourcing/purchase-order/RegistrationModal.tsx @@ -0,0 +1,1798 @@ +"use client"; + +/** + * 외주발주 등록/수정 모달 (TASK:ERP-019) + * + * 좌우 분할 (1700px): + * 좌측 — 3개 근거 탭 (수주/작업지시/품목정보) + 검색/리스트 + * 우측 — 선택 품목 + 공정 매핑 (Accordion 트리) + 공정 자동표기 + * 푸터 — 통계 + 저장 + */ + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Check, + ChevronDown, + ChevronLeft, + ChevronRight as ChevronRightIcon, + ChevronsLeft, + ChevronsRight, + ChevronsUpDown, + Loader2, + RefreshCw, + Save, + Search as SearchIcon, + Trash2, + UserCircle, + X, + Package, +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { apiClient } from "@/lib/api/client"; + +import { + createOutsourcePurchaseOrder, + updateOutsourcePurchaseOrder, + getOutsourcePurchaseOrder, + getAutoProcesses, + getProcessMaterials, + listSubcontractors, + listOutsourceableWorkOrders, + previewOrderNo, + type OPOInputPayload, + type OPOProcess, +} from "@/lib/api/outsourcePurchase"; + +// ───────────────────────────────────────────────────────────────────────────── +// 타입 +// ───────────────────────────────────────────────────────────────────────────── + +type SourceTab = "수주" | "작업지시" | "품목정보"; + +interface SourceRow { + id: string; + source_type: SourceTab; + source_no: string; + item_code: string; + item_name: string; + spec?: string; + material?: string; + /** 단위 (품목정보 탭에서 표시) */ + unit?: string; + /** 진행상태 라벨 (작업지시 탭에서 표시) */ + status_label?: string; + quantity?: number | string; + customer_name?: string; + due_date?: string; + /** 원본 행(작업지시 자동표기 등에서 사용할 추가 필드) */ + raw?: any; +} + +interface CategoryOption { + code: string; + label: string; +} + +interface SelectedItemEntry { + /** 일관된 키 (소스+품목코드 기반) */ + key: string; + source_type: SourceTab; + source_no: string; + item_code: string; + item_name: string; + spec?: string; + material?: string; + quantity: number | string; + due_date?: string; + processes: OPOProcess[]; + /** 작업지시 ID (있을 경우 자동표기 시 진행상황 분석에 사용) */ + work_order_id?: string; +} + +interface SubcontractorOption { + id: string; // subcontractor_mng.id (그룹핑·저장 키) + code: string; // 표시용 + name: string; +} + +interface UserOption { + user_id: string; // 저장 키 (불변) + user_name: string; // 화면 표시 + dept_code?: string; +} + +/** + * 탭별 페이징 캐시 — 페이지 번호 방식 (TASK:ERP-019 재구현) + * - rows: 현재 페이지의 데이터만 (누적 X) + * - page: 현재 페이지 번호 + * - total: 전체 건수 (서버 응답) + * - search: 캐시 적용 시점의 검색어 (검색어 바뀌면 캐시 무효) + * - loadedAt: 캐시 적재 시각 + */ +interface TabCache { + rows: SourceRow[]; + page: number; + total: number; + search: string; + loadedAt: number; +} + +interface RegistrationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** 수정 모드일 때 외주발주 ID */ + editId?: string | null; + onSaved: () => void; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 컴포넌트 +// ───────────────────────────────────────────────────────────────────────────── + +export function RegistrationModal({ open, onOpenChange, editId, onSaved }: RegistrationModalProps) { + // 헤더 입력 + const [orderDate, setOrderDate] = useState(new Date().toISOString().slice(0, 10)); + const [dueDate, setDueDate] = useState(""); + const [manager, setManager] = useState(""); + const [memo, setMemo] = useState(""); + // 외주발주번호 — 등록된 채번 규칙 미리보기 + 사용자 수정 가능 + // - orderNo: input의 현재 값 (사용자 입력 또는 미리보기) + // - originalPreview: 모달 진입 시 받은 미리보기 값 (사용자 수정 여부 판단) + const [orderNo, setOrderNo] = useState(""); + const [originalPreview, setOriginalPreview] = useState(""); + + // 탭 + const [activeTab, setActiveTab] = useState("수주"); + + // 좌측: 소스 검색 + 서버 페이징 + /** + * 페이징·캐시 정책 (TASK:ERP-019 재구현) + * - 좌측 리스트는 서버 페이징(size: 50)으로 한 페이지씩 표시 + * - 탭별 캐시(rows·page·total·검색어) 유지 → 탭 전환 시 같은 페이지 즉시 표시 + * - 검색어 변경 시 해당 탭 캐시 무효화 후 page=1 재조회 (디바운스 300ms) + * - 페이지네이션 버튼 — [<<][<][1][2][3][>][>>] 클릭 시 다음 페이지 로드 + * - 자동 누적 로드 없음 (사용자 지적: 3,000+ 건 자동 누적 방지) + */ + const PAGE_SIZE = 50; + const SEARCH_DEBOUNCE_MS = 300; + const [sourceSearch, setSourceSearch] = useState(""); + const [sourceLoading, setSourceLoading] = useState(false); + const [tabCache, setTabCache] = useState>({ + 수주: null, + 작업지시: null, + 품목정보: null, + }); + // 탭별 현재 페이지 (사용자가 보고 있는 페이지) + const [tabPage, setTabPage] = useState>({ + 수주: 1, + 작업지시: 1, + 품목정보: 1, + }); + // 디바운스용 검색어 (탭별) + const [debouncedSearch, setDebouncedSearch] = useState(""); + + // 수주 탭 매핑용 mng 캐시 — order_no → 거래처/납기 보강 + // size: 0 (전체) 1회만 로드, 모달이 열려있는 동안 재사용 + const [mngByOrderNo, setMngByOrderNo] = useState | null>(null); + + // 우측: 선택된 품목 목록 + const [selected, setSelected] = useState([]); + const [expanded, setExpanded] = useState>({}); + + // 외주사 옵션 + const [vendors, setVendors] = useState([]); + + // 회사 사용자 옵션 (담당자 셀렉트용) + const [users, setUsers] = useState([]); + const [managerComboOpen, setManagerComboOpen] = useState(false); + + // 카테고리 옵션 (item_info: material/division/type/inventory_unit) + const [categoryOptions, setCategoryOptions] = useState>({}); + // 거래처 코드 → 이름 맵 (수주 탭 partner_id 변환) + const [customerMap, setCustomerMap] = useState>({}); + // 품목코드(item_number) → item_info 행 맵 (작업지시 탭에서 item_id/품목코드 매핑) + const [itemInfoByCode, setItemInfoByCode] = useState>({}); + // 품목ID(id) → item_info 행 맵 (작업지시 탭 item_id 기반 매핑) + const [itemInfoById, setItemInfoById] = useState>({}); + // 마스터 로드 완료 플래그 + const [mastersLoaded, setMastersLoaded] = useState(false); + + // 저장 진행 + const [saving, setSaving] = useState(false); + + // 수정모드 데이터 로드 진행 + const [loadingExisting, setLoadingExisting] = useState(false); + + // ───────────────────────────────────────────────────────────────────────── + // 외주사 옵션 로드 + // ───────────────────────────────────────────────────────────────────────── + useEffect(() => { + let alive = true; + (async () => { + try { + const rows = await listSubcontractors(); + if (!alive) return; + const opts: SubcontractorOption[] = rows + .map((r: any) => ({ + id: r.id || "", + code: r.subcontractor_code || r.code || r.id || "", + name: r.subcontractor_name || r.name || r.subcontractor_code || "", + })) + .filter((o: SubcontractorOption) => o.id && o.name); + setVendors(opts); + } catch { + // 외주사 마스터 미존재 시 무시 + } + })(); + return () => { + alive = false; + }; + }, []); + + // ───────────────────────────────────────────────────────────────────────── + // 회사 사용자 옵션 로드 (담당자 셀렉트용) + // - autoFilter: 현재 회사 필터 자동 적용 + // - size: 0 → 전체 (CLAUDE.md 규칙: 마스터 참조 데이터는 size:0) + // ───────────────────────────────────────────────────────────────────────── + useEffect(() => { + let alive = true; + (async () => { + try { + const res = await apiClient.post(`/table-management/tables/user_info/data`, { + page: 1, size: 0, autoFilter: true, + }); + if (!alive) return; + const rows = res.data?.data?.data || res.data?.data?.rows || []; + const opts: UserOption[] = rows + .map((u: any) => ({ + user_id: u.user_id || u.id || "", + user_name: u.user_name || u.name || u.user_id || "", + dept_code: u.dept_code || "", + })) + .filter((o: UserOption) => o.user_id && o.user_name); + setUsers(opts); + } catch { + // 사용자 마스터 조회 실패 시 무시 (input 자유 입력으로 폴백) + } + })(); + return () => { + alive = false; + }; + }, []); + + // ───────────────────────────────────────────────────────────────────────── + // 카테고리 옵션 + 거래처 마스터 + 품목정보 마스터 로드 + // (좌측 리스트의 코드 → 라벨 변환에 사용) + // ───────────────────────────────────────────────────────────────────────── + useEffect(() => { + if (!open) return; + let alive = true; + (async () => { + try { + // 1) item_info 카테고리 (material / division / type / inventory_unit) + const catCols = ["material", "division", "type", "inventory_unit"]; + const optMap: Record = {}; + await Promise.all( + catCols.map(async (col) => { + try { + const r = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (r.data?.success) { + const rows = r.data.data || []; + // 트리/플랫 모두 처리 (children 평탄화) + // 응답 필드: valueCode / valueLabel (camelCase) + const flat: CategoryOption[] = []; + const walk = (arr: any[]) => { + for (const c of arr) { + const code = c?.valueCode ?? c?.code; + const label = c?.valueLabel ?? c?.label ?? c?.name ?? code; + if (code) flat.push({ code: String(code), label: String(label) }); + if (Array.isArray(c?.children) && c.children.length) walk(c.children); + } + }; + walk(rows); + optMap[col] = flat; + } + } catch { + /* skip */ + } + }), + ); + + // 2) 거래처 마스터 (수주 탭 partner_id → 거래처명) + const custMap: Record = {}; + try { + const cr = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, + size: 0, + autoFilter: true, + }); + const custs = cr.data?.data?.data || cr.data?.data?.rows || []; + for (const c of custs) { + const code = c.customer_code || c.code || c.id; + const name = c.customer_name || c.name || code; + if (code) custMap[String(code)] = String(name); + } + } catch { + /* skip */ + } + + // 3) 품목정보 마스터 (작업지시 탭 item_id → 품명·규격·재질) + const byCode: Record = {}; + const byId: Record = {}; + try { + const ir = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, + size: 0, + autoFilter: true, + }); + const items = ir.data?.data?.data || ir.data?.data?.rows || []; + for (const it of items) { + const code = it.item_number || it.item_code; + if (code) byCode[String(code)] = it; + if (it.id) byId[String(it.id)] = it; + } + } catch { + /* skip */ + } + + if (!alive) return; + setCategoryOptions(optMap); + setCustomerMap(custMap); + setItemInfoByCode(byCode); + setItemInfoById(byId); + setMastersLoaded(true); + } catch (e) { + console.error("[OPO] 마스터 로드 실패:", e); + if (alive) setMastersLoaded(true); + } + })(); + return () => { + alive = false; + }; + }, [open]); + + // ───────────────────────────────────────────────────────────────────────── + // 수정 모드 초기 로드 + // ───────────────────────────────────────────────────────────────────────── + useEffect(() => { + if (!editId) return; + let alive = true; + (async () => { + setLoadingExisting(true); + try { + const detail = await getOutsourcePurchaseOrder(editId); + if (!alive || !detail) return; + // 수정 모드: 기존 발주번호 그대로 사용 (수정 불가, 재할당 금지) + setOrderNo(detail.order_no || ""); + setOriginalPreview(detail.order_no || ""); + setOrderDate(detail.order_date || ""); + setDueDate(detail.due_date || ""); + setManager(detail.manager || ""); + setMemo(detail.memo || ""); + const entry: SelectedItemEntry = { + key: `${detail.source_type}_${detail.item_code}_${detail.id}`, + source_type: (detail.source_type as SourceTab) || "품목정보", + source_no: detail.source_no || "", + item_code: detail.item_code || "", + item_name: detail.item_name || "", + spec: detail.spec || "", + material: detail.material || "", + quantity: detail.quantity || 0, + due_date: detail.due_date || "", + processes: (detail.processes || []) as OPOProcess[], + }; + setSelected([entry]); + setExpanded({ [entry.key]: true }); + } catch (e: any) { + toast.error(e?.message || "외주발주 정보를 불러오지 못했어요"); + } finally { + if (alive) setLoadingExisting(false); + } + })(); + return () => { + alive = false; + }; + }, [editId]); + + // ───────────────────────────────────────────────────────────────────────── + // 카테고리 코드 → 라벨 변환 헬퍼 + // ───────────────────────────────────────────────────────────────────────── + const resolveCategory = useCallback( + (col: string, code: any) => { + const c = code == null ? "" : String(code); + if (!c) return ""; + const opts = categoryOptions[col]; + if (!opts || opts.length === 0) return c; + const found = opts.find((o) => String(o.code) === c); + return found ? found.label : c; + }, + [categoryOptions], + ); + + // 작업지시 진행상태 코드 → 라벨 (DB 값: completed/in_progress/pending 또는 한글) + const resolveProgressStatus = useCallback((code: any) => { + if (!code) return ""; + const s = String(code); + const map: Record = { + completed: "완료", + in_progress: "진행중", + pending: "대기", + ready: "대기", + cancelled: "취소", + }; + return map[s] || s; + }, []); + + // ───────────────────────────────────────────────────────────────────────── + // 탭별 검색 컬럼 (서버 측 dataFilter — OR 매칭) + // 수주: order_no, part_name, part_code, spec + // 작업지시: work_instruction_no, item_name (item_id 기반이라 코드 검색 어려움) + // 품목정보: item_number, item_name, size + // ───────────────────────────────────────────────────────────────────────── + const buildDataFilter = useCallback((tab: SourceTab, keyword: string) => { + const k = (keyword || "").trim(); + if (!k) return undefined; + let cols: string[] = []; + if (tab === "수주") cols = ["order_no", "part_name", "part_code", "spec"]; + // work_instruction은 item_name 컬럼이 없고 item_id만 있어 번호 검색만 지원 + else if (tab === "작업지시") cols = ["work_instruction_no"]; + else cols = ["item_number", "item_name", "size"]; + return { + enabled: true, + // 백엔드 dataFilterUtil.matchType: "any" → OR 결합 + matchType: "any" as const, + filters: cols.map((c) => ({ + id: `f_${c}`, + columnName: c, + operator: "contains" as const, + value: k, + valueType: "static" as const, + })), + }; + }, []); + + // ───────────────────────────────────────────────────────────────────────── + // 응답 행 → SourceRow 변환 (탭별) + // 부분 페이지 데이터에 대해서도 동일하게 동작 + // ───────────────────────────────────────────────────────────────────────── + const mapSalesOrderRows = useCallback( + (detailRows: any[], mngMap: Record): SourceRow[] => { + return detailRows.map((d: any) => { + const mng = mngMap[String(d.order_no || "")] || {}; + const partnerCode = mng.partner_id || d.partner_id || ""; + const partnerName = customerMap[String(partnerCode)] || partnerCode; + return { + id: String(d.id || `${d.order_no}_${d.part_code}_${d.seq_no || ""}`), + source_type: "수주", + source_no: d.order_no || "", + item_code: d.part_code || "", + item_name: d.part_name || "", + spec: d.spec || "", + material: resolveCategory("material", d.material), + quantity: d.qty || 0, + customer_name: partnerName, + due_date: d.due_date || mng.due_date || mng.order_date || "", + raw: { ...d, _mng: mng }, + }; + }); + }, + [customerMap, resolveCategory], + ); + + const mapWorkInstructionRows = useCallback( + (raw: any[]): SourceRow[] => { + return raw.map((r: any) => { + const item = + (r.item_id && itemInfoById[String(r.item_id)]) || + (r.item_number && itemInfoByCode[String(r.item_number)]) || + null; + const itemCode = item?.item_number || r.item_number || r.item_code || ""; + const itemName = item?.item_name || r.item_name || ""; + const itemSpec = item?.size || r.spec || r.item_spec || ""; + const itemMatRaw = item?.material || r.material || ""; + const statusCode = r.progress_status || r.status || ""; + // TASK:ERP-019 — 다음 외주공정 표기용 (status_label 자리에 "다음공정명" 우선 표시) + const nextProcName: string | undefined = r._next_process_name; + const outsourceCount: number | undefined = r._outsourceable_count; + const statusLabel = nextProcName + ? `${nextProcName}${outsourceCount && outsourceCount > 1 ? ` 외 ${outsourceCount - 1}` : ""}` + : resolveProgressStatus(statusCode); + return { + id: String(r.id || r.work_instruction_no), + source_type: "작업지시", + source_no: r.work_instruction_no || r.wi_no || "", + item_code: itemCode, + item_name: itemName, + spec: itemSpec, + material: resolveCategory("material", itemMatRaw), + quantity: r.qty || r.quantity || 0, + status_label: statusLabel, + due_date: r.end_date || r.due_date || "", + raw: r, + }; + }); + }, + [itemInfoById, itemInfoByCode, resolveCategory, resolveProgressStatus], + ); + + const mapItemInfoRows = useCallback( + (raw: any[]): SourceRow[] => { + return raw.map((r: any) => ({ + id: String(r.id || r.item_number || r.item_code), + source_type: "품목정보", + source_no: r.item_number || r.item_code || "", + item_code: r.item_number || r.item_code || "", + item_name: r.item_name || "", + spec: r.size || r.spec || "", + material: resolveCategory("material", r.material), + unit: resolveCategory("inventory_unit", r.inventory_unit) || r.unit || "", + quantity: 0, + raw: r, + })); + }, + [resolveCategory], + ); + + // ───────────────────────────────────────────────────────────────────────── + // 수주 탭 mng 매핑 캐시 — 1회 size:0 마스터 로드, 모달 동안 재사용 + // 주의: size:0 은 마스터 참조용으로만 허용된 패턴 (CLAUDE.md 규약) + // ───────────────────────────────────────────────────────────────────────── + const ensureMngCache = useCallback(async (): Promise> => { + if (mngByOrderNo) return mngByOrderNo; + try { + const mngRes = await apiClient.post(`/table-management/tables/sales_order_mng/data`, { + page: 1, + size: 0, + autoFilter: true, + }); + const mngRows: any[] = mngRes.data?.data?.data || mngRes.data?.data?.rows || []; + const map: Record = {}; + for (const m of mngRows) { + const key = String(m.order_no || ""); + if (!key) continue; + if (!map[key]) map[key] = m; + } + setMngByOrderNo(map); + return map; + } catch { + const empty: Record = {}; + setMngByOrderNo(empty); + return empty; + } + }, [mngByOrderNo]); + + // ───────────────────────────────────────────────────────────────────────── + // 한 페이지 서버 조회 (탭/페이지/검색어 기준) + // ───────────────────────────────────────────────────────────────────────── + const fetchPage = useCallback( + async ( + tab: SourceTab, + page: number, + keyword: string, + ): Promise<{ rows: SourceRow[]; total: number }> => { + const dataFilter = buildDataFilter(tab, keyword); + if (tab === "수주") { + const [detailRes, mngMap] = await Promise.all([ + apiClient.post(`/table-management/tables/sales_order_detail/data`, { + page, + size: PAGE_SIZE, + dataFilter, + autoFilter: true, + }), + ensureMngCache(), + ]); + const detailRows: any[] = + detailRes.data?.data?.data || detailRes.data?.data?.rows || []; + const total = Number( + detailRes.data?.data?.total ?? detailRes.data?.data?.totalCount ?? detailRows.length, + ); + return { rows: mapSalesOrderRows(detailRows, mngMap), total }; + } + if (tab === "작업지시") { + // TASK:ERP-019 재구현 — 외주발주 가능 작업지시만 노출 (백엔드 필터링) + // "이전 공정 완료 + 다음 공정 외주/선택가능" 조건 충족하는 작업지시만 반환 + const filtered = await listOutsourceableWorkOrders({ + page, + size: PAGE_SIZE, + keyword: keyword || undefined, + }); + // OutsourceableWorkOrderRow → 기존 work_instruction 행 모양으로 매핑하여 mapWorkInstructionRows 통과 + const raw = filtered.rows.map((r) => ({ + id: r.id, + work_instruction_no: r.work_instruction_no, + qty: r.qty, + status: r.status, + progress_status: r.progress_status, + item_id: r.item_id, + start_date: r.start_date, + end_date: r.end_date, + // 추가 메타 — 다음 외주공정 정보 (UI에서 표시 가능) + _next_process_name: r.next_process_name, + _next_process_seq: r.next_process_seq, + _outsourceable_count: r.outsourceable_count, + })); + return { rows: mapWorkInstructionRows(raw), total: filtered.total }; + } + // 품목정보 + const res = await apiClient.post(`/table-management/tables/item_info/data`, { + page, + size: PAGE_SIZE, + dataFilter, + autoFilter: true, + }); + const raw: any[] = res.data?.data?.data || res.data?.data?.rows || []; + const total = Number(res.data?.data?.total ?? res.data?.data?.totalCount ?? raw.length); + return { rows: mapItemInfoRows(raw), total }; + }, + [ + buildDataFilter, + ensureMngCache, + mapItemInfoRows, + mapSalesOrderRows, + mapWorkInstructionRows, + ], + ); + + // ───────────────────────────────────────────────────────────────────────── + // 특정 페이지 로드 (탭 진입/검색 변경/페이지 변경/새로고침) + // 캐시가 있고 검색어·페이지가 같으면 재사용, 다르면 새로 조회 + // ───────────────────────────────────────────────────────────────────────── + const loadPage = useCallback( + async (tab: SourceTab, page: number, keyword: string, force: boolean) => { + const cached = tabCache[tab]; + if (!force && cached && cached.search === keyword && cached.page === page) return; + setSourceLoading(true); + try { + const { rows, total } = await fetchPage(tab, page, keyword); + const next: TabCache = { + rows, + page, + total, + search: keyword, + loadedAt: Date.now(), + }; + setTabCache((prev) => ({ ...prev, [tab]: next })); + } catch (e: any) { + toast.error(e?.message || "소스 데이터 조회 실패"); + } finally { + setSourceLoading(false); + } + }, + [fetchPage, tabCache], + ); + + // 새로고침 — 현재 탭·페이지 강제 재조회 + const refreshCurrentTab = useCallback(() => { + loadPage(activeTab, tabPage[activeTab] || 1, debouncedSearch, true); + }, [activeTab, debouncedSearch, loadPage, tabPage]); + + // 페이지 변경 핸들러 — 사용자가 페이지 버튼 클릭 시 + const changePage = useCallback( + (tab: SourceTab, page: number) => { + const cur = tabCache[tab]; + const total = cur?.total || 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const safe = Math.min(Math.max(1, page), totalPages); + setTabPage((prev) => ({ ...prev, [tab]: safe })); + loadPage(tab, safe, debouncedSearch, false); + }, + [debouncedSearch, loadPage, tabCache], + ); + + // 탭 전환 — 해당 탭의 현재 페이지 로드 (캐시 있으면 즉시 표시) + useEffect(() => { + if (!open) return; + if (editId) return; + if (!mastersLoaded) return; + loadPage(activeTab, tabPage[activeTab] || 1, debouncedSearch, false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, editId, activeTab, mastersLoaded]); + + // 검색어 디바운스 — 300ms 후 적용 + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(sourceSearch), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(t); + }, [sourceSearch]); + + // 디바운스 검색어 변경 → 활성 탭 page=1 재조회 + useEffect(() => { + if (!open) return; + if (editId) return; + if (!mastersLoaded) return; + const cur = tabCache[activeTab]; + if (cur && cur.search === debouncedSearch && cur.page === 1) return; + setTabPage((prev) => ({ ...prev, [activeTab]: 1 })); + loadPage(activeTab, 1, debouncedSearch, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch, mastersLoaded]); + + // 모달 닫힐 때 캐시 초기화 (다음 오픈 시 fresh) + useEffect(() => { + if (open) return; + setTabCache({ 수주: null, 작업지시: null, 품목정보: null }); + setTabPage({ 수주: 1, 작업지시: 1, 품목정보: 1 }); + setMngByOrderNo(null); + setSourceSearch(""); + setDebouncedSearch(""); + setOrderNo(""); + setOriginalPreview(""); + }, [open]); + + // 신규 등록 — 모달 open 시 외주발주번호 미리보기 채움 (시퀀스 증가 없음) + useEffect(() => { + if (!open || editId) return; + let alive = true; + (async () => { + const code = await previewOrderNo(); + if (!alive) return; + setOrderNo(code || ""); + setOriginalPreview(code || ""); + })(); + return () => { + alive = false; + }; + }, [open, editId]); + + // ───────────────────────────────────────────────────────────────────────── + // 좌측 → 우측 추가 + // (TASK:ERP-019 재구현 — 작업지시 행이면 즉시 공정 자동표기 호출) + // ───────────────────────────────────────────────────────────────────────── + const addItemToSelected = (row: SourceRow) => { + const key = `${row.source_type}_${row.item_code || row.source_no}_${row.id}`; + let alreadyAdded = false; + setSelected((prev) => { + if (prev.find((p) => p.key === key)) { + alreadyAdded = true; + return prev; + } + const entry: SelectedItemEntry = { + key, + source_type: row.source_type, + source_no: row.source_no, + item_code: row.item_code, + item_name: row.item_name, + spec: row.spec, + material: row.material, + quantity: row.quantity || 0, + due_date: row.due_date, + processes: [], + work_order_id: row.source_type === "작업지시" ? row.id : undefined, + }; + setExpanded((p) => ({ ...p, [key]: true })); + return [...prev, entry]; + }); + + // 작업지시 행이면 즉시 자동표기 (work_order_process 기준) + if (!alreadyAdded && row.source_type === "작업지시") { + // 비동기 실행 — 추가 직후 setSelected 반영을 기다리지 않고 바로 호출 + void runAutoFillForKey(key, { workOrderId: row.id, itemCode: row.item_code, silent: true }); + } + }; + + const removeSelectedItem = (key: string) => { + setSelected((prev) => prev.filter((p) => p.key !== key)); + }; + + const clearSelected = () => { + setSelected([]); + }; + + // ───────────────────────────────────────────────────────────────────────── + // 공정 자동표기 (TASK:ERP-019 재구현) + // - 작업지시 ID가 있으면 work_order_process 기준 (1순위) + // - 없으면 item_code 기준 (폴백) + // - silent=true 인 경우 toast 생략 (행 추가 즉시 자동 호출용) + // ───────────────────────────────────────────────────────────────────────── + const runAutoFillForKey = useCallback( + async ( + key: string, + args: { workOrderId?: string; itemCode?: string; silent?: boolean }, + ) => { + try { + const res = await getAutoProcesses({ + workOrderId: args.workOrderId, + itemCode: args.itemCode, + }); + const candidates = res.candidates || []; + if (candidates.length === 0) { + if (!args.silent) { + toast.warning("자동표기 가능한 외주공정이 없어요. 수동으로 추가해주세요."); + } + return; + } + const processes: OPOProcess[] = candidates.map((c, i) => { + const opts: { id: string; code: string; name: string }[] = Array.isArray((c as any).vendor_options) + ? (c as any).vendor_options.filter((o: any) => o?.id && o?.name) + : []; + // 외주사 후보가 단 1곳이면 자동 선택, 여러 곳이면 비워두고 사용자 선택 유도 + const auto = opts.length === 1 ? opts[0] : null; + // routing_detail_id: work_order 모드는 wop.routing_detail_id, item_routing 폴백은 rd.id + const rdid = (c as any).routing_detail_id || (c as any).id || ""; + return { + seq: i + 1, + process_code: c.process_code, + process_name: c.process_name || c.process_code, + vendor_id: auto?.id || "", + vendor_code: auto?.code || "", + vendor_name: auto?.name || "", + material_needed: false, + materials: [], + vendor_options: opts, + routing_detail_id: rdid, + }; + }); + setSelected((prev) => + prev.map((s) => (s.key === key ? { ...s, processes } : s)), + ); + if (!args.silent) { + toast.success(`외주공정 ${processes.length}개를 자동으로 표기했어요`); + } + } catch (e: any) { + if (!args.silent) toast.error(e?.message || "공정 자동표기 실패"); + } + }, + [], + ); + + const removeProcess = (key: string, seq: number) => { + setSelected((prev) => + prev.map((s) => + s.key === key + ? { + ...s, + processes: s.processes + .filter((p) => p.seq !== seq) + .map((p, i) => ({ ...p, seq: i + 1 })), + } + : s, + ), + ); + }; + + const updateProcess = (key: string, seq: number, patch: Partial) => { + setSelected((prev) => + prev.map((s) => + s.key === key + ? { + ...s, + processes: s.processes.map((p) => (p.seq === seq ? { ...p, ...patch } : p)), + } + : s, + ), + ); + }; + + const updateProcessVendor = (key: string, seq: number, vendorId: string) => { + // 우선순위: 공정의 vendor_options(라우팅 매핑) → 전체 vendors 마스터 + const entry = selected.find((s) => s.key === key); + const proc = entry?.processes.find((p) => p.seq === seq); + const fromOptions = proc?.vendor_options?.find((o) => o.id === vendorId); + const fromMaster = vendors.find((vv) => vv.id === vendorId); + const v = fromOptions || fromMaster; + updateProcess(key, seq, { + vendor_id: vendorId, + vendor_code: v?.code || "", + vendor_name: v?.name || "", + }); + }; + + // 사급자재 체크박스 토글 — ON이면 공정작업기준 material_input을 자재 목록에 자동 채움 + const toggleMaterialNeeded = async (key: string, seq: number, checked: boolean) => { + if (!checked) { + updateProcess(key, seq, { material_needed: false }); + return; + } + const entry = selected.find((s) => s.key === key); + const proc = entry?.processes.find((p) => p.seq === seq); + if (!proc) return; + + // 이미 자재가 있으면 덮어쓰지 않고 체크만 ON + if ((proc.materials || []).length > 0) { + updateProcess(key, seq, { material_needed: true }); + return; + } + if (!proc.routing_detail_id) { + updateProcess(key, seq, { material_needed: true }); + toast.warning("라우팅 정보가 없어 자재 자동 채움 불가. [+ 자재] 버튼으로 직접 추가해주세요."); + return; + } + try { + const mats = await getProcessMaterials({ + workOrderId: entry?.work_order_id, + routingDetailId: proc.routing_detail_id, + }); + if (mats.length === 0) { + updateProcess(key, seq, { material_needed: true }); + toast.info("이 공정에 등록된 자재투입 항목이 없어요. 직접 추가해주세요."); + return; + } + updateProcess(key, seq, { + material_needed: true, + materials: mats.map((m) => ({ + item_code: m.item_code, + item_name: m.item_name, + qty: m.qty, + unit: m.unit, + release_status: "대기", + })), + }); + toast.success(`자재 ${mats.length}건 자동 채움 완료`); + } catch (e: any) { + updateProcess(key, seq, { material_needed: true }); + toast.error(e?.message || "자재 자동 채움 실패"); + } + }; + + // ───────────────────────────────────────────────────────────────────────── + // 사급자재 편집 + // ───────────────────────────────────────────────────────────────────────── + const addMaterialToProcess = (key: string, seq: number) => { + setSelected((prev) => + prev.map((s) => { + if (s.key !== key) return s; + return { + ...s, + processes: s.processes.map((p) => + p.seq === seq + ? { + ...p, + material_needed: true, + materials: [ + ...(p.materials || []), + { item_code: "", item_name: "", qty: 0, unit: "EA", release_status: "대기" }, + ], + } + : p, + ), + }; + }), + ); + }; + + const updateMaterial = (key: string, seq: number, idx: number, patch: any) => { + setSelected((prev) => + prev.map((s) => { + if (s.key !== key) return s; + return { + ...s, + processes: s.processes.map((p) => + p.seq === seq + ? { + ...p, + materials: (p.materials || []).map((m, i) => (i === idx ? { ...m, ...patch } : m)), + } + : p, + ), + }; + }), + ); + }; + + const removeMaterial = (key: string, seq: number, idx: number) => { + setSelected((prev) => + prev.map((s) => { + if (s.key !== key) return s; + return { + ...s, + processes: s.processes.map((p) => + p.seq === seq + ? { ...p, materials: (p.materials || []).filter((_, i) => i !== idx) } + : p, + ), + }; + }), + ); + }; + + // ───────────────────────────────────────────────────────────────────────── + // 현재 탭의 표시 데이터 — 서버 페이징 결과(현재 페이지)만 사용 + // (검색은 dataFilter로 서버에서 처리됨) + // ───────────────────────────────────────────────────────────────────────── + const currentCache = tabCache[activeTab]; + const sourceRows: SourceRow[] = currentCache?.rows || []; + const totalCount: number = currentCache?.total || 0; + const currentPage: number = currentCache?.page || tabPage[activeTab] || 1; + const totalPages: number = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + // ───────────────────────────────────────────────────────────────────────── + // 통계 + // ───────────────────────────────────────────────────────────────────────── + const stats = useMemo(() => { + const itemCount = selected.length; + const processCount = selected.reduce((acc, s) => acc + s.processes.length, 0); + const materialCount = selected.reduce( + (acc, s) => acc + s.processes.reduce((a, p) => a + ((p.materials || []).length || 0), 0), + 0, + ); + return { itemCount, processCount, materialCount }; + }, [selected]); + + // ───────────────────────────────────────────────────────────────────────── + // 저장 + // ───────────────────────────────────────────────────────────────────────── + const handleSave = async () => { + if (selected.length === 0) { + toast.error("발주할 품목을 선택해주세요"); + return; + } + setSaving(true); + try { + // 수정 모드: 1건만 (선택은 1건만 유지) + // 등록 모드: 선택된 N건 각각 별도 발주로 등록 + if (editId) { + const entry = selected[0]; + const payload: OPOInputPayload = { + source_type: entry.source_type, + source_no: entry.source_no, + item_code: entry.item_code, + item_name: entry.item_name, + spec: entry.spec, + material: entry.material, + quantity: entry.quantity, + order_date: orderDate || null, + due_date: dueDate || null, + manager, + memo, + processes: entry.processes, + }; + await updateOutsourcePurchaseOrder(editId, payload); + toast.success("외주발주가 수정되었어요"); + } else { + // 신규 등록 — 외주발주번호: + // - 사용자가 미리보기 값을 그대로 둠 → payload.order_no 비움 (백엔드 allocate) + // - 사용자가 직접 수정 → 그 값 그대로 사용 (UNIQUE 충돌 시 백엔드에서 에러) + // - 단, 첫 1건에만 사용자 입력 적용 (2건 이상은 모두 자동채번) + const userTyped = orderNo.trim() !== "" && orderNo.trim() !== originalPreview.trim(); + let success = 0; + let firstUserOrderNo: string | undefined = userTyped ? orderNo.trim() : undefined; + for (const entry of selected) { + const payload: OPOInputPayload = { + source_type: entry.source_type, + source_no: entry.source_no, + item_code: entry.item_code, + item_name: entry.item_name, + spec: entry.spec, + material: entry.material, + quantity: entry.quantity, + order_date: orderDate || null, + due_date: dueDate || entry.due_date || null, + manager, + memo, + processes: entry.processes, + ...(firstUserOrderNo ? { order_no: firstUserOrderNo } : {}), + }; + await createOutsourcePurchaseOrder(payload); + firstUserOrderNo = undefined; // 첫 건 사용 후 비움 — 이후는 자동채번 + success++; + } + toast.success(`${success}건의 외주발주가 등록되었어요`); + } + onSaved(); + } catch (e: any) { + toast.error(e?.response?.data?.message || e?.message || "저장 실패"); + } finally { + setSaving(false); + } + }; + + // ───────────────────────────────────────────────────────────────────────── + // 렌더 + // ───────────────────────────────────────────────────────────────────────── + const titleNode = ( +
+
외주발주
+ + {editId ? "외주발주 수정" : "외주발주 등록"} + + + {orderNo || "(자동채번)"} + +
+ ); + + const footer = ( +
+
+ 선택 품목 {stats.itemCount}건 + 공정 {stats.processCount}건 + + 사급자재 {stats.materialCount}건 + +
+
+ + +
+
+ ); + + return ( + +
+ {/* 헤더 메타 */} +
+
+ + setOrderNo(e.target.value)} + readOnly={!!editId} + placeholder="(자동채번)" + className="h-8 bg-white text-xs" + title={editId ? "기존 외주발주번호는 수정할 수 없습니다" : "기본값은 채번 규칙으로 자동 생성됩니다. 필요 시 직접 입력 가능."} + /> +
+
+ + setOrderDate(v || "")} placeholder="발주일" /> +
+
+ + setDueDate(v || "")} placeholder="납기일" /> +
+
+ + + + + + + + + + 사용자를 찾을 수 없어요 + + {/* 선택 해제 옵션 */} + {manager ? ( + { + setManager(""); + setManagerComboOpen(false); + }} + className="text-xs text-slate-500" + > + + 선택 해제 + + ) : null} + {users.map((u) => ( + { + setManager(u.user_id); + setManagerComboOpen(false); + }} + className="text-xs" + > + + +
+ {u.user_name} + {u.dept_code ? ( + {u.dept_code} + ) : null} +
+
+ ))} +
+
+
+
+
+
+
+ + {/* 좌우 분할 */} + {loadingExisting ? ( +
+ +
+ ) : ( + + {/* 좌측 — 근거 탭 + 검색 + 리스트 */} + +
+ setActiveTab(v as SourceTab)} + className="flex h-full min-h-0 flex-col" + > +
+ + 📝 수주 + 🔧 작업지시 + 📋 품목정보 + +
+ +
+ + setSourceSearch(e.target.value)} + className="h-7 text-xs" + placeholder={`번호·품명·규격 검색 (서버 검색)`} + /> + +
+ + {/* 카운트 표시 — 현재 페이지 범위/전체 */} +
+ + {totalCount > 0 + ? `${((currentPage - 1) * PAGE_SIZE + 1).toLocaleString()} - ${Math.min(currentPage * PAGE_SIZE, totalCount).toLocaleString()}` + : "0"}{" "} + / {totalCount.toLocaleString()}건 + {currentCache?.search ? ( + 검색: "{currentCache.search}" + ) : null} + + + {totalPages > 0 ? `${currentPage} / ${totalPages} 페이지` : ""} + +
+ + + {/* + 좌측 리스트 — 페이지네이션 방식 (TASK:ERP-019 재구현) + - 컨테이너: flex-1 + min-h-0 → ResizablePanel 내에서 가용 높이 차지 + - 내부 스크롤: overflow-y-auto (페이지 데이터 50건만 표시) + - 자동 누적 없음 (사용자가 페이지 버튼 클릭해야 다음 페이지 로드) + */} +
+
+ + selected.some( + (s) => s.item_code === row.item_code && s.source_no === row.source_no, + ) + } + /> +
+ + {/* 페이지네이션 컨트롤 — 하단 고정 */} + {totalCount > 0 && ( +
+ + + + {/* 페이지 번호 (현재 ±2) */} + {(() => { + const pages: (number | "...")[] = []; + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) pages.push(i); + } else { + pages.push(1); + if (currentPage > 3) pages.push("..."); + for ( + let i = Math.max(2, currentPage - 1); + i <= Math.min(totalPages - 1, currentPage + 1); + i++ + ) { + pages.push(i); + } + if (currentPage < totalPages - 2) pages.push("..."); + pages.push(totalPages); + } + return pages.map((p, i) => + p === "..." ? ( + + … + + ) : ( + + ), + ); + })()} + + + +
+ )} +
+
+
+
+
+ + + + {/* 우측 — 선택 품목 + 공정 매핑 */} + +
+
+ 🎯 선택 품목 ({selected.length}건) +
+ +
+
+
+ {selected.length === 0 ? ( +
+ 좌측에서 품목을 선택해주세요 +
+ ) : ( + selected.map((entry) => ( + + setExpanded((p) => ({ ...p, [entry.key]: !p[entry.key] })) + } + vendors={vendors} + onRemoveProcess={(seq) => removeProcess(entry.key, seq)} + onUpdateProcess={(seq, patch) => updateProcess(entry.key, seq, patch)} + onUpdateVendor={(seq, code) => updateProcessVendor(entry.key, seq, code)} + onToggleMaterialNeeded={(seq, checked) => toggleMaterialNeeded(entry.key, seq, checked)} + onAddMaterial={(seq) => addMaterialToProcess(entry.key, seq)} + onUpdateMaterial={(seq, idx, patch) => updateMaterial(entry.key, seq, idx, patch)} + onRemoveMaterial={(seq, idx) => removeMaterial(entry.key, seq, idx)} + onRemoveItem={() => removeSelectedItem(entry.key)} + /> + )) + )} +
+
+
+
+ )} + + {/* 메모 */} +
+
+ +