From 77d5b52265d18e83cd3c9ee3c95a35e185d8b0a6 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 7 Apr 2026 11:52:13 +0900 Subject: [PATCH] 11 --- .../purchase/purchase-item/page.tsx | 1312 +++++++++++------ .../COMPANY_16/purchase/supplier/page.tsx | 130 +- .../(main)/COMPANY_16/sales/customer/page.tsx | 130 +- .../COMPANY_16/sales/sales-item/page.tsx | 286 ++-- 4 files changed, 1183 insertions(+), 675 deletions(-) diff --git a/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx index 5061ce09..1f7c4137 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx @@ -1,15 +1,25 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +/** + * 구매품목관리 — Type B 마스터-디테일 리디자인 + * + * 좌측: 구매품목 목록 (item_info, 구매 관련 필터) + * 우측: 선택한 품목의 공급업체 정보 (supplier_item_mapping → supplier_mng 조인) + * + * 공급업체관리와 양방향 연동 (같은 supplier_item_mapping 테이블) + */ + +import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Truck, Search, Settings2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Search, X, Settings2, Package } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; @@ -17,8 +27,8 @@ import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { exportToExcel } from "@/lib/utils/excelExport"; -import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { useTableSettings } from "@/hooks/useTableSettings"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; @@ -26,32 +36,46 @@ const ITEM_TABLE = "item_info"; const MAPPING_TABLE = "supplier_item_mapping"; const SUPPLIER_TABLE = "supplier_mng"; -const ITEM_COLUMNS = [ +// 숫자 포맷 헬퍼 +const formatNum = (val: any): string => { + if (val === null || val === undefined || val === "") return ""; + const n = Number(val); + return isNaN(n) ? String(val) : n.toLocaleString(); +}; + +const ITEM_GRID_COLUMNS = [ + { key: "item_number", label: "품번" }, + { key: "item_name", label: "품명" }, { key: "size", label: "규격" }, { key: "unit", label: "단위" }, { key: "standard_price", label: "기준단가" }, + { key: "standard_price", label: "구매단가" }, + { key: "currency_code", label: "통화" }, { key: "status", label: "상태" }, ]; export default function PurchaseItemPage() { const { user } = useAuth(); - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); - - // 검색 필터 (DynamicSearchFilter) - const [searchFilters, setSearchFilters] = useState([]); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + const ts = useTableSettings("c16-purchase-item", ITEM_TABLE, ITEM_GRID_COLUMNS); // 좌측: 품목 const [items, setItems] = useState([]); const [itemLoading, setItemLoading] = useState(false); + const [itemCount, setItemCount] = useState(0); const [selectedItemId, setSelectedItemId] = useState(null); + // 검색 필터 (DynamicSearchFilter에서 관리) + const [searchFilters, setSearchFilters] = useState([]); + // 우측: 공급업체 const [supplierItems, setSupplierItems] = useState([]); const [supplierLoading, setSupplierLoading] = useState(false); const [supplierCheckedIds, setSupplierCheckedIds] = useState([]); // 카테고리 - const [categoryOptions, setCategoryOptions] = useState>({}); + const [categoryOptions, setCategoryOptions] = useState>({}); + const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); // 공급업체 추가 모달 const [suppSelectOpen, setSuppSelectOpen] = useState(false); @@ -60,160 +84,194 @@ export default function PurchaseItemPage() { const [suppSearchLoading, setSuppSearchLoading] = useState(false); const [suppCheckedIds, setSuppCheckedIds] = useState>(new Set()); - // 공급업체 상세 입력 모달 - const [suppDetailOpen, setSuppDetailOpen] = useState(false); - const [selectedSuppsForDetail, setSelectedSuppsForDetail] = useState([]); - const [suppMappings, setSuppMappings] = useState>({}); - const [editSuppData, setEditSuppData] = useState(null); - const [saving, setSaving] = useState(false); - // 품목 수정 모달 const [editItemOpen, setEditItemOpen] = useState(false); const [editItemForm, setEditItemForm] = useState>({}); + const [saving, setSaving] = useState(false); // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); - // 테이블 설정 - const ts = useTableSettings("c16-purchase-item", ITEM_TABLE, ITEM_COLUMNS); + + // 공급업체 상세 입력 모달 (공급업체 품번/품명 + 단가) + const [suppDetailOpen, setSuppDetailOpen] = useState(false); + const [selectedSuppsForDetail, setSelectedSuppsForDetail] = useState([]); + const [suppMappings, setSuppMappings] = useState>>({}); + const [suppPrices, setSuppPrices] = useState>>({}); + const [editSuppData, setEditSuppData] = useState(null); + // 카테고리 로드 useEffect(() => { const load = async () => { - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => { + const result: { code: string; label: string; isDefault?: boolean }[] = []; for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); + result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); if (v.children?.length) result.push(...flatten(v.children)); } return result; }; - for (const col of ["currency_code", "status"]) { + for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } setCategoryOptions(optMap); + + // 단가 카테고리 + const priceOpts: Record = {}; + for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { + try { + const res = await apiClient.get(`/table-categories/supplier_item_prices/${col}/values`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); }; load(); }, []); - // 구매품 division 코드 조회 - const [purchaseDivCodes, setPurchaseDivCodes] = useState([]); - useEffect(() => { - const loadDiv = async () => { - try { - const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/division/values`); - const flatten = (vals: any[]): { code: string; label: string }[] => { - const r: { code: string; label: string }[] = []; - for (const v of vals) { r.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) r.push(...flatten(v.children)); } - return r; - }; - const all = flatten(res.data?.data || []); - const codes = all.filter(o => o.label.includes("구매")).map(o => o.code); - setPurchaseDivCodes(codes); - } catch { /* skip */ } - }; - loadDiv(); - }, []); + const resolve = (col: string, code: string) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; - // 좌측: 품목 조회 (관리품목이 구매관리/구매품인 것만) + // 좌측: 품목 조회 const fetchItems = useCallback(async () => { - if (purchaseDivCodes.length === 0) return; setItemLoading(true); try { - const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); - // division 필터 추가: 구매 관련 코드만 (in 연산자) - const allFilters = [ - ...filters, - { columnName: "division", operator: "in", value: purchaseDivCodes }, - ]; + const filters: { columnName: string; operator: string; value: any }[] = []; + + // 구매품목/영업관리 division 필터 (다중값 컬럼이므로 contains로 매칭) + filters.push({ columnName: "division", operator: "contains", value: "s" }); + + // DynamicSearchFilter에서 전달된 필터 추가 + for (const f of searchFilters) { + filters.push({ columnName: f.columnName, operator: f.operator, value: f.value }); + } + const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, - dataFilter: { enabled: true, filters: allFilters }, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - setItems(res.data?.data?.data || res.data?.data?.rows || []); - } catch { + const raw = res.data?.data?.data || res.data?.data?.rows || []; + const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]; + const data = raw.map((r: any) => { + const converted = { ...r }; + for (const col of CATS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + setItems(data); + setItemCount(res.data?.data?.total || raw.length); + } catch (err) { + console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했습니다."); } finally { setItemLoading(false); } - }, [searchFilters, purchaseDivCodes]); + }, [searchFilters, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { fetchItems(); }, [fetchItems]); + // 선택된 품목 const selectedItem = items.find((i) => i.id === selectedItemId); - const isColVisible = (key: string) => ts.isVisible(key); - const itemColSpan = 2 + ITEM_COLUMNS.filter((c) => isColVisible(c.key)).length; - const mainTableColumns = useMemo(() => { - const cols: EDataTableColumn[] = [ - { key: "item_number", label: "품번", width: "w-[110px]" }, - { key: "item_name", label: "품명" }, - ]; - if (isColVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" }); - if (isColVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" }); - if (isColVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true }); - if (isColVisible("status")) cols.push({ - key: "status", label: "상태", width: "w-[60px]", align: "center", - render: (v) => ( - {v || "-"} - ), - }); - return cols; - }, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps - - // 우측: 공급업체 매핑 조회 + // 우측: 공급업체 목록 조회 useEffect(() => { if (!selectedItem?.item_number) { setSupplierItems([]); setSupplierCheckedIds([]); return; } setSupplierCheckedIds([]); const itemKey = selectedItem.item_number; - const fetchSupplierMappings = async () => { + const fetchSupplierItems = async () => { setSupplierLoading(true); try { + // 1. supplier_item_mapping에서 해당 품목의 매핑 조회 const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { page: 1, size: 500, dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, autoFilter: true, }); const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; - const suppIds = [...new Set(mappings.map((m: any) => m.supplier_id).filter(Boolean))]; - let suppMap: Record = {}; - if (suppIds.length > 0) { + + // 2. supplier_id → supplier_mng 조인 (공급업체명) + const custIds = [...new Set(mappings.map((m: any) => m.supplier_id).filter(Boolean))]; + let custMap: Record = {}; + if (custIds.length > 0) { try { - const suppRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { - page: 1, size: suppIds.length + 10, - dataFilter: { enabled: true, filters: [{ columnName: "supplier_code", operator: "in", value: suppIds }] }, + const custRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { + page: 1, size: custIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "supplier_code", operator: "in", value: custIds }] }, autoFilter: true, }); - for (const s of (suppRes.data?.data?.data || suppRes.data?.data?.rows || [])) { - suppMap[s.supplier_code] = s; + for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) { + custMap[c.supplier_code] = c; } } catch { /* skip */ } } - setSupplierItems(mappings.map((m: any) => ({ - ...m, - supplier_code: m.supplier_id || "", - supplier_name: suppMap[m.supplier_id]?.supplier_name || "", - }))); - } catch { - toast.error("공급업체 정보를 불러오는데 실패했습니다."); + + // 3. supplier_item_prices 조회 (단가 정보) + let allPrices: any[] = []; + if (mappings.length > 0) { + try { + const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [ + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, + autoFilter: true, + }); + allPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + } catch { /* skip */ } + } + + // 4. 공급업체별 중복 제거 + 오늘 날짜 기준 단가 매칭 + const priceResolve = (col: string, code: string) => { + if (!code) return ""; + return priceCategoryOptions[col]?.find((o: any) => o.code === code)?.label || code; + }; + const today = new Date().toISOString().split("T")[0]; + const seenCustIds = new Set(); + const sortedMappings = [...mappings].sort((a: any, b: any) => (a.supplier_id || "").localeCompare(b.supplier_id || "")); + + setSupplierItems(sortedMappings.map((m: any) => { + const custKey = m.supplier_id || ""; + const isFirstOfGroup = !seenCustIds.has(custKey); + if (custKey) seenCustIds.add(custKey); + + const custPriceList = allPrices.filter((p: any) => p.supplier_id === custKey); + const todayPrice = custPriceList.find((p: any) => + (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today) + ) || custPriceList[0] || {}; + + return { + ...m, + supplier_code: isFirstOfGroup ? custKey : "", + supplier_name: isFirstOfGroup ? (custMap[custKey]?.supplier_name || "") : "", + supplier_item_code: m.supplier_item_code || "", + supplier_item_name: m.supplier_item_name || "", + base_price: todayPrice.base_price || "", + calculated_price: todayPrice.calculated_price || todayPrice.unit_price || "", + currency_code: priceResolve("currency_code", todayPrice.currency_code || ""), + }; + })); + } catch (err) { + console.error("공급업체 조회 실패:", err); } finally { setSupplierLoading(false); } }; - fetchSupplierMappings(); - }, [selectedItem?.item_number]); + fetchSupplierItems(); + }, [selectedItem?.item_number, priceCategoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps // 공급업체 검색 const searchSuppliers = async () => { @@ -227,117 +285,261 @@ export default function PurchaseItemPage() { autoFilter: true, }); const all = res.data?.data?.data || res.data?.data?.rows || []; - const existing = new Set(supplierItems.map((s: any) => s.supplier_id)); - setSuppSearchResults(all.filter((s: any) => !existing.has(s.supplier_code))); + // 이미 등록된 공급업체 제외 + const existing = new Set(supplierItems.map((c: any) => c.supplier_id || c.supplier_code)); + setSuppSearchResults(all.filter((c: any) => !existing.has(c.supplier_code))); } catch { /* skip */ } finally { setSuppSearchLoading(false); } }; - // 단가 자동 계산 - const calcPrice = (base: string, discType: string, discVal: string): string => { - const bp = Number(base) || 0; - const dv = Number(discVal) || 0; - if (discType === "rate") return String(Math.round(bp * (1 - dv / 100))); - if (discType === "amount") return String(Math.round(bp - dv)); - return String(bp); - }; - + // 공급업체 선택 → 상세 모달로 이동 const goToSuppDetail = () => { - const selected = suppSearchResults.filter((s) => suppCheckedIds.has(s.id)); + const selected = suppSearchResults.filter((c) => suppCheckedIds.has(c.id)); if (selected.length === 0) { toast.error("공급업체를 선택해주세요."); return; } setSelectedSuppsForDetail(selected); const mappings: typeof suppMappings = {}; - for (const supp of selected) { - const key = supp.supplier_code || supp.id; - mappings[key] = { - supplier_item_code: "", supplier_item_name: "", - base_price: selectedItem?.standard_price || "", discount_type: "none", - discount_value: "", calculated_price: selectedItem?.standard_price || "", - currency_code: "", start_date: "", end_date: "", - lead_time_days: "", min_order_qty: "", - }; + const prices: typeof suppPrices = {}; + for (const cust of selected) { + const key = cust.supplier_code || cust.id; + mappings[key] = []; + prices[key] = [{ + _id: `p_${Date.now()}_${Math.random()}`, + start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.standard_price || "", + discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", + calculated_price: selectedItem?.standard_price || selectedItem?.standard_price || "", + }]; } setSuppMappings(mappings); + setSuppPrices(prices); setSuppSelectOpen(false); - setEditSuppData(null); setSuppDetailOpen(true); }; - const updateMapping = (suppKey: string, field: string, value: string) => { - setSuppMappings((prev) => { - const cur = prev[suppKey] || {} as any; - const updated = { ...cur, [field]: value }; - if (["base_price", "discount_type", "discount_value"].includes(field)) { - updated.calculated_price = calcPrice(updated.base_price, updated.discount_type, updated.discount_value); - } - return { ...prev, [suppKey]: updated }; - }); + const addMappingRow = (custKey: string) => { + setSuppMappings((prev) => ({ + ...prev, + [custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, supplier_item_code: "", supplier_item_name: "" }], + })); }; - const openEditSupp = (row: any) => { - const suppKey = row.supplier_id || row.supplier_code; - setSelectedSuppsForDetail([{ supplier_code: suppKey, supplier_name: row.supplier_name || "" }]); - setSuppMappings({ - [suppKey]: { - supplier_item_code: row.supplier_item_code || "", - supplier_item_name: row.supplier_item_name || "", - base_price: row.base_price ? String(row.base_price) : "", - discount_type: row.discount_type || "none", - discount_value: row.discount_value ? String(row.discount_value) : "", - calculated_price: row.calculated_price ? String(row.calculated_price) : "", - currency_code: row.currency_code || "", - start_date: row.start_date ? String(row.start_date).split("T")[0] : "", - end_date: row.end_date ? String(row.end_date).split("T")[0] : "", - lead_time_days: row.lead_time_days ? String(row.lead_time_days) : "", - min_order_qty: row.min_order_qty ? String(row.min_order_qty) : "", - }, - }); + const removeMappingRow = (custKey: string, rowId: string) => { + setSuppMappings((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId), + })); + }; + + const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => { + setSuppMappings((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r), + })); + }; + + const addPriceRow = (custKey: string) => { + setSuppPrices((prev) => ({ + ...prev, + [custKey]: [...(prev[custKey] || []), { + _id: `p_${Date.now()}_${Math.random()}`, + start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", + discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", + calculated_price: "", + }], + })); + }; + + const removePriceRow = (custKey: string, rowId: string) => { + setSuppPrices((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId), + })); + }; + + const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => { + setSuppPrices((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).map((r) => { + if (r._id !== rowId) return r; + const updated = { ...r, [field]: value }; + if (["base_price", "discount_type", "discount_value", "rounding_unit_value", "rounding_type"].includes(field)) { + const bp = Number(updated.base_price) || 0; + const dv = Number(updated.discount_value) || 0; + const dt = updated.discount_type; + let calc = bp; + if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100); + else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv; + // 반올림 유형 + 단위 적용 + const rv = updated.rounding_unit_value; + const rt = updated.rounding_type; + const roundOpts = priceCategoryOptions["rounding_unit_value"] || []; + const roundLabel = roundOpts.find((o) => o.code === rv)?.label || ""; + const unitOpts = priceCategoryOptions["rounding_type"] || []; + const unitLabel = unitOpts.find((o) => o.code === rt)?.label || ""; + const unit = parseInt(unitLabel) || 1; + if (roundLabel === "반올림") calc = Math.round(calc / unit) * unit; + else if (roundLabel === "절삭") calc = Math.floor(calc / unit) * unit; + else if (roundLabel === "올림") calc = Math.ceil(calc / unit) * unit; + updated.calculated_price = String(Math.floor(calc)); + } + return updated; + }), + })); + }; + + const openEditSupp = async (row: any) => { + const custKey = row.supplier_code || row.supplier_id; + + // supplier_mng에서 공급업체 정보 조회 + let custInfo: any = { supplier_code: custKey, supplier_name: row.supplier_name || "" }; + try { + const res = await apiClient.post(`/table-management/tables/supplier_mng/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "supplier_code", operator: "equals", value: custKey }] }, + autoFilter: true, + }); + const found = (res.data?.data?.data || res.data?.data?.rows || [])[0]; + if (found) custInfo = found; + } catch { /* skip */ } + + const mappingRows = [{ + _id: `m_existing_${row.id}`, + supplier_item_code: row.supplier_item_code || "", + supplier_item_name: row.supplier_item_name || "", + }].filter((m) => m.supplier_item_code || m.supplier_item_name); + + const priceRows = [{ + _id: `p_existing_${row.id}`, + start_date: row.start_date || "", + end_date: row.end_date || "", + currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", + base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", + base_price: row.base_price ? String(row.base_price) : "", + discount_type: row.discount_type || "", + discount_value: row.discount_value ? String(row.discount_value) : "", + rounding_type: row.rounding_type || "", + rounding_unit_value: row.rounding_unit_value || "", + calculated_price: row.calculated_price ? String(row.calculated_price) : "", + }].filter((p) => p.base_price || p.start_date); + + if (priceRows.length === 0) { + priceRows.push({ + _id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "", + rounding_type: "", rounding_unit_value: "", calculated_price: "", + }); + } + + setSelectedSuppsForDetail([custInfo]); + setSuppMappings({ [custKey]: mappingRows }); + setSuppPrices({ [custKey]: priceRows }); setEditSuppData(row); setSuppDetailOpen(true); }; const handleSuppDetailSave = async () => { if (!selectedItem) return; - const isEdit = !!editSuppData; + const isEditingExisting = !!editSuppData; setSaving(true); try { - for (const supp of selectedSuppsForDetail) { - const suppKey = supp.supplier_code || supp.id; - const m = suppMappings[suppKey]; - if (!m) continue; - const fields: Record = { - supplier_id: suppKey, item_id: selectedItem.item_number, - supplier_item_code: m.supplier_item_code || null, - supplier_item_name: m.supplier_item_name || null, - base_price: m.base_price ? Number(m.base_price) : null, - discount_type: m.discount_type === "none" ? null : m.discount_type || null, - discount_value: m.discount_value ? Number(m.discount_value) : null, - calculated_price: m.calculated_price ? Number(m.calculated_price) : null, - currency_code: m.currency_code || null, - start_date: m.start_date || null, - end_date: m.end_date || null, - lead_time_days: m.lead_time_days ? Number(m.lead_time_days) : null, - min_order_qty: m.min_order_qty ? Number(m.min_order_qty) : null, - }; - if (isEdit && editSuppData?.id) { + for (const cust of selectedSuppsForDetail) { + const custKey = cust.supplier_code || cust.id; + const mappingRows = suppMappings[custKey] || []; + + if (isEditingExisting && editSuppData?.id) { await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { - originalData: { id: editSuppData.id }, updatedData: fields, + originalData: { id: editSuppData.id }, + updatedData: { + supplier_item_code: mappingRows[0]?.supplier_item_code || "", + supplier_item_name: mappingRows[0]?.supplier_item_name || "", + }, }); + + // 기존 prices 삭제 후 재등록 + try { + const existingPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "mapping_id", operator: "equals", value: editSuppData.id }, + ]}, autoFilter: true, + }); + const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; + if (existing.length > 0) { + await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, { + data: existing.map((p: any) => ({ id: p.id })), + }); + } + } catch { /* skip */ } + + const priceRows = (suppPrices[custKey] || []).filter((p) => + (p.base_price && Number(p.base_price) > 0) || p.start_date + ); + for (const price of priceRows) { + await apiClient.post(`/table-management/tables/supplier_item_prices/add`, { + id: crypto.randomUUID(), + mapping_id: editSuppData.id, + supplier_id: custKey, + item_id: selectedItem.item_number, + start_date: price.start_date || null, end_date: price.end_date || null, + currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, + base_price: price.base_price ? Number(price.base_price) : null, + discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, + rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, + calculated_price: price.calculated_price ? Number(price.calculated_price) : null, + }); + } } else { - await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { id: crypto.randomUUID(), ...fields }); + // 신규 등록 + const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + id: crypto.randomUUID(), + supplier_id: custKey, item_id: selectedItem.item_number, + supplier_item_code: mappingRows[0]?.supplier_item_code || "", + supplier_item_name: mappingRows[0]?.supplier_item_name || "", + }); + const mappingId = mappingRes.data?.data?.id || null; + + for (let mi = 1; mi < mappingRows.length; mi++) { + await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + id: crypto.randomUUID(), + supplier_id: custKey, item_id: selectedItem.item_number, + supplier_item_code: mappingRows[mi].supplier_item_code || "", + supplier_item_name: mappingRows[mi].supplier_item_name || "", + }); + } + + const priceRows = (suppPrices[custKey] || []).filter((p) => + (p.base_price && Number(p.base_price) > 0) || p.start_date + ); + for (const price of priceRows) { + await apiClient.post(`/table-management/tables/supplier_item_prices/add`, { + id: crypto.randomUUID(), + mapping_id: mappingId || "", supplier_id: custKey, item_id: selectedItem.item_number, + start_date: price.start_date || null, end_date: price.end_date || null, + currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, + base_price: price.base_price ? Number(price.base_price) : null, + discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, + rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, + calculated_price: price.calculated_price ? Number(price.calculated_price) : null, + }); + } } } - toast.success(isEdit ? "수정되었습니다." : `${selectedSuppsForDetail.length}개 공급업체가 추가되었습니다.`); + toast.success(isEditingExisting ? "수정되었습니다." : `${selectedSuppsForDetail.length}개 공급업체가 추가되었습니다.`); setSuppDetailOpen(false); setEditSuppData(null); setSuppCheckedIds(new Set()); + // 우측 새로고침 const sid = selectedItemId; setSelectedItemId(null); setTimeout(() => setSelectedItemId(sid), 50); } catch (err: any) { toast.error(err.response?.data?.message || "저장에 실패했습니다."); - } finally { setSaving(false); } + } finally { + setSaving(false); + } }; + // 품목 수정 const openEditItem = () => { if (!selectedItem) return; setEditItemForm({ ...selectedItem }); @@ -360,14 +562,37 @@ export default function PurchaseItemPage() { fetchItems(); } catch (err: any) { toast.error(err.response?.data?.message || "수정에 실패했습니다."); - } finally { setSaving(false); } + } finally { + setSaving(false); + } }; + // 우측: 공급업체 매핑 삭제 const handleSupplierMappingDelete = async () => { if (supplierCheckedIds.length === 0) return; - const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체 매핑을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체 매핑을 삭제하시겠습니까?`, { + description: "관련된 단가 정보도 함께 삭제됩니다.", + variant: "destructive", confirmText: "삭제", + }); if (!ok) return; try { + // 관련 단가 삭제 + for (const mappingId of supplierCheckedIds) { + try { + const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + if (prices.length > 0) { + await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, { + data: prices.map((p: any) => ({ id: p.id })), + }); + } + } catch { /* skip */ } + } + // 매핑 삭제 await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { data: supplierCheckedIds.map((id) => ({ id })), }); @@ -376,166 +601,216 @@ export default function PurchaseItemPage() { const sid = selectedItemId; setSelectedItemId(null); setTimeout(() => setSelectedItemId(sid), 50); - } catch { toast.error("삭제에 실패했습니다."); } + } catch { + toast.error("삭제에 실패했습니다."); + } }; + // 엑셀 다운로드 const handleExcelDownload = async () => { if (items.length === 0) return; - await exportToExcel(items.map((i) => ({ + const data = items.map((i) => ({ 품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit, - 기준단가: i.standard_price, 통화: i.currency_code, 상태: i.status, - })), "구매품목정보.xlsx", "구매품목"); + 기준단가: i.standard_price, 구매단가: i.standard_price, 통화: i.currency_code, 상태: i.status, + })); + await exportToExcel(data, "구매품목관리.xlsx", "구매품목"); toast.success("다운로드 완료"); }; + // EDataTable 컬럼 정의 (구매품목) + const itemColumns: EDataTableColumn[] = [ + { key: "item_number", label: "품번", width: "w-[110px]" }, + { key: "item_name", label: "품명", minWidth: "min-w-[130px]" }, + { key: "size", label: "규격", width: "w-[80px]" }, + { key: "unit", label: "단위", width: "w-[60px]" }, + { key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "standard_price", label: "구매단가", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "currency_code", label: "통화", width: "w-[50px]" }, + { key: "status", label: "상태", width: "w-[60px]" }, + ]; + return ( -
- {/* 검색 바 */} +
+ {/* 검색 필터 (DynamicSearchFilter) */} - - -
- } /> - {/* 분할 패널 */} -
- - {/* 좌측: 구매품목 */} - + {/* 액션 버튼 영역 */} +
+
+ + +
+
+ + {/* 마스터-디테일 분할 패널 */} +
+ + {/* 좌측: 구매품목 목록 */} +
-
-
-

구매품목 목록

- {items.length}건 - {itemLoading && } + {/* 패널 헤더 */} +
+
+ 구매품목 목록 + + {itemCount}건 + +
+
+ +
-
+ + {/* 거래처 테이블 */} row.id} loading={itemLoading} emptyMessage="등록된 구매품목이 없어요" selectedId={selectedItemId} onSelect={(id) => setSelectedItemId(id)} onRowDoubleClick={() => openEditItem()} - showPagination={true} + showRowNumber + showPagination + defaultPageSize={20} draggableColumns={false} - columnOrderKey="c16-purchase-item-main" + columnOrderKey="c16-purchase-item" />
- {/* 우측: 공급업체 정보 */} - + {/* 우측: 디테일 패널 */} +
{!selectedItemId ? ( + /* 빈 상태 */
-
- -
품목을 선택해주세요
+
+ +
품목을 선택해주세요
좌측에서 품목을 선택하면 공급업체 정보가 표시돼요
) : ( <> -
-
-

{selectedItem?.item_name || "-"}

- {selectedItem?.item_number || ""} -
- -
-
-
- 공급업체별 단가 - {supplierItems.length}건 + {/* 공급업체별 단가 헤더 */} +
+
+ + 공급업체별 단가 + + {supplierItems.length} +
- -
-
- - - - - 0 && supplierCheckedIds.length === supplierItems.length} - onCheckedChange={(checked) => { - if (checked) setSupplierCheckedIds(supplierItems.map((s) => s.id)); - else setSupplierCheckedIds([]); - }} - /> - - 공급업체코드 - 공급업체명 - 공급업체품번 - 기준가 - 단가 - 통화 - 리드타임 - - - - {supplierLoading ? ( - - ) : supplierItems.length === 0 ? ( - 등록된 공급업체가 없어요 - ) : supplierItems.map((s) => ( - openEditSupp(s)} - onClick={() => setSupplierCheckedIds((prev) => { - const next = [...prev]; - const idx = next.indexOf(s.id); - if (idx >= 0) next.splice(idx, 1); else next.push(s.id); - return next; - })} - > - e.stopPropagation()}> - setSupplierCheckedIds((prev) => - checked ? [...prev, s.id] : prev.filter((id) => id !== s.id) - )} + + {/* 공급업체 테이블 */} +
+ {supplierLoading ? ( +
+ +
+ ) : supplierItems.length === 0 ? ( +
+ 등록된 공급업체가 없어요 +
+ ) : ( +
+ + + + 0 && supplierCheckedIds.length === supplierItems.length} + onChange={(e) => { + if (e.target.checked) setSupplierCheckedIds(supplierItems.map((c) => c.id)); + else setSupplierCheckedIds([]); + }} /> - - {s.supplier_code} - {s.supplier_name} - {s.supplier_item_code || "-"} - {s.base_price ? Number(s.base_price).toLocaleString() : "-"} - {s.calculated_price ? Number(s.calculated_price).toLocaleString() : "-"} - {s.currency_code || "-"} - {s.lead_time_days ? `${s.lead_time_days}일` : "-"} + + 공급업체코드 + 공급업체명 + 공급업체품번 + 공급업체품명 + 기준가 + 단가 + 통화 - ))} - -
+ + + {supplierItems.map((row) => ( + openEditSupp(row)} + > + { + e.stopPropagation(); + setSupplierCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + + + {row.supplier_code} + {row.supplier_name} + {row.supplier_item_code} + {row.supplier_item_name} + + {row.base_price ? Number(row.base_price).toLocaleString() : ""} + + + {row.calculated_price ? Number(row.calculated_price).toLocaleString() : ""} + + {row.currency_code} + + ))} + + + )}
)} @@ -544,35 +819,65 @@ export default function PurchaseItemPage() {
- {/* 품목 수정 모달 */} + {/* ── 품목 수정 모달 ── */} 구매품목 수정 - {editItemForm.item_number} — {editItemForm.item_name} + + {editItemForm.item_number || ""} — {editItemForm.item_name || ""} + -
- {([ - { key: "item_number", label: "품목코드" }, { key: "item_name", label: "품명" }, - { key: "size", label: "규격" }, { key: "unit", label: "단위" }, - { key: "material", label: "재질" }, { key: "status", label: "상태" }, - ] as { key: string; label: string }[]).map((f) => ( +
+ {/* 품목 기본정보 (읽기 전용) */} + {[ + { key: "item_number", label: "품목코드" }, + { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, + { key: "unit", label: "단위" }, + { key: "material", label: "재질" }, + { key: "status", label: "상태" }, + ].map((f) => (
- +
))} +
+ + {/* 구매 설정 (수정 가능) */} +
+ + setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))} + placeholder="구매단가를 입력해주세요" + className="h-9" + /> +
- setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))} placeholder="기준단가" className="h-9" /> + setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))} + placeholder="기준단가를 입력해주세요" + className="h-9" + />
- setEditItemForm((p) => ({ ...p, currency_code: v }))} + > + + + - {(categoryOptions["currency_code"] || []).map((o) => {o.label})} + {(categoryOptions["currency_code"] || []).map((o) => ( + {o.label} + ))}
@@ -580,62 +885,92 @@ export default function PurchaseItemPage() {
- {/* 공급업체 선택 모달 */} + {/* ── 공급업체 검색 및 추가 모달 ── */} - 공급업체 선택 - 품목에 추가할 공급업체를 선택하세요. + 공급업체 검색 및 추가 + 품목에 추가할 공급업체를 선택해주세요. -
- setSuppSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && searchSuppliers()} - className="h-9 flex-1" /> - -
-
- - - - - 0 && suppCheckedIds.size === suppSearchResults.length} - onCheckedChange={(checked) => { - if (checked) setSuppCheckedIds(new Set(suppSearchResults.map((s) => s.id))); - else setSuppCheckedIds(new Set()); - }} - /> - - 공급업체코드 - 공급업체명 - 담당자 - 연락처 - - - - {suppSearchResults.length === 0 ? ( - 검색 결과가 없어요 - ) : suppSearchResults.map((s) => ( - setSuppCheckedIds((prev) => { const next = new Set(prev); if (next.has(s.id)) next.delete(s.id); else next.add(s.id); return next; })}> - {}} /> - {s.supplier_code} - {s.supplier_name} - {s.contact_person} - {s.contact_phone} +
+ {/* 검색바 */} +
+ setSuppSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchSuppliers()} + className="h-9 flex-1" + /> + +
+ {/* 검색 결과 테이블 */} +
+
+ + + + 0 && suppCheckedIds.size === suppSearchResults.length} + onCheckedChange={(checked) => { + if (checked === true) setSuppCheckedIds(new Set(suppSearchResults.map((c) => c.id))); + else setSuppCheckedIds(new Set()); + }} + /> + + 공급업체코드 + 공급업체명 + 거래유형 + 담당자 - ))} - -
+ + + {suppSearchResults.length === 0 ? ( + + + 검색 결과가 없어요 + + + ) : suppSearchResults.map((c) => ( + setSuppCheckedIds((prev) => { + const next = new Set(prev); + if (next.has(c.id)) next.delete(c.id); else next.add(c.id); + return next; + })} + > + + { + setSuppCheckedIds((prev) => { + const next = new Set(prev); + if (checked === true) next.add(c.id); else next.delete(c.id); + return next; + }); + }} + /> + + {c.supplier_code} + {c.supplier_name} + {c.division} + {c.contact_person} + + ))} + + +
@@ -643,7 +978,8 @@ export default function PurchaseItemPage() {
@@ -651,85 +987,207 @@ export default function PurchaseItemPage() { - {/* 공급업체 상세 입력/수정 모달 */} + {/* ── 공급업체 상세 입력/수정 모달 ── */} - + - 공급업체 매핑 {editSuppData ? "수정" : "등록"} — {selectedItem?.item_name || ""} - {editSuppData ? "공급업체 품번/단가 정보를 수정합니다." : "공급업체별 품번과 단가를 입력합니다."} + + 공급업체 상세정보 {editSuppData ? "수정" : "입력"} — {selectedItem?.item_name || ""} + + + {editSuppData ? "공급업체 품번/품명과 기간별 단가를 수정해주세요." : "선택한 공급업체의 품번/품명과 기간별 단가를 설정해주세요."} + -
- {selectedSuppsForDetail.map((supp, idx) => { - const suppKey = supp.supplier_code || supp.id; - const m = suppMappings[suppKey] || {} as any; + +
+ {selectedSuppsForDetail.map((cust, idx) => { + const custKey = cust.supplier_code || cust.id; + const mappingRows = suppMappings[custKey] || []; + const prices = suppPrices[custKey] || []; + return ( -
-
- {idx + 1}. {supp.supplier_name || suppKey} - {suppKey} +
+ {/* 공급업체 헤더 */} +
+ {idx + 1}. {cust.supplier_name || custKey} + + {custKey} +
-
-
-
- - updateMapping(suppKey, "supplier_item_code", e.target.value)} placeholder="공급업체 자체 품번" className="h-9 text-sm" /> + +
+ {/* 좌: 공급업체 품번/품명 */} +
+
+ 공급업체 품번/품명 관리 +
-
- - updateMapping(suppKey, "supplier_item_name", e.target.value)} placeholder="공급업체 자체 품명" className="h-9 text-sm" /> +
+ {mappingRows.length === 0 ? ( +
입력된 공급업체 품번이 없어요
+ ) : mappingRows.map((mRow, mIdx) => ( +
+ {mIdx + 1} + updateMappingRow(custKey, mRow._id, "supplier_item_code", e.target.value)} + placeholder="공급업체 품번" + className="h-8 text-sm flex-1" + /> + updateMappingRow(custKey, mRow._id, "supplier_item_name", e.target.value)} + placeholder="공급업체 품명" + className="h-8 text-sm flex-1" + /> + +
+ ))}
-
- 단가 정보 -
-
- - updateMapping(suppKey, "base_price", e.target.value)} className="h-8 text-xs text-right" placeholder="0" /> -
-
- - -
-
- - updateMapping(suppKey, "discount_value", e.target.value)} className="h-8 text-xs text-right" placeholder="0" /> -
-
- - -
+ + {/* 우: 기간별 단가 */} +
+
+ 기간별 단가 설정 +
-
-
- - updateMapping(suppKey, "currency_code", e.target.value)} className="h-8 text-xs" placeholder="KRW" /> -
-
- - updateMapping(suppKey, "start_date", e.target.value)} className="h-8 text-xs" /> -
-
- - updateMapping(suppKey, "end_date", e.target.value)} className="h-8 text-xs" /> -
-
-
-
-
- - updateMapping(suppKey, "lead_time_days", e.target.value)} className="h-8 text-xs" placeholder="0" /> -
-
- - updateMapping(suppKey, "min_order_qty", e.target.value)} className="h-8 text-xs" placeholder="0" /> -
+
+ {prices.map((price, pIdx) => ( +
+
+ 단가 {pIdx + 1} + {prices.length > 1 && ( + + )} +
+ {/* 기간 + 통화 */} +
+ updatePriceRow(custKey, price._id, "start_date", e.target.value)} + className="h-8 text-xs flex-1" + /> + ~ + updatePriceRow(custKey, price._id, "end_date", e.target.value)} + className="h-8 text-xs flex-1" + /> +
+ +
+
+ {/* 기준유형 + 기준가 */} +
+ + { + const raw = e.target.value.replace(/[^\d.-]/g, ""); + updatePriceRow(custKey, price._id, "base_price", raw); + }} + className="h-8 text-xs text-right col-span-3" + placeholder="기준가" + /> +
+ {/* 할인 + 반올림 */} +
+ + { + const raw = e.target.value.replace(/[^\d.-]/g, ""); + updatePriceRow(custKey, price._id, "discount_value", raw); + }} + className="h-8 text-xs text-right" + placeholder="0" + /> + + +
+ {/* 계산 단가 */} +
+ 계산 단가: + + {price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"} + +
+
+ ))}
@@ -737,21 +1195,26 @@ export default function PurchaseItemPage() { ); })}
+ - +
- - {/* 테이블 설정 모달 */} + {/* 엑셀 업로드 */} + fetchItems()} + /> + {ConfirmDialogComponent}
); diff --git a/frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx index 8910a0d7..51c50aa5 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx @@ -127,6 +127,7 @@ export default function SupplierManagementPage() { const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); + const [itemTotalCount, setItemTotalCount] = useState(0); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); @@ -817,12 +818,13 @@ export default function SupplierManagementPage() { autoFilter: true, }); const allItems = res.data?.data?.data || res.data?.data?.rows || []; + setItemTotalCount(allItems.length); const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number)); - const PURCHASE_CODE = "CAT_MMDJB7R4_TO3T"; + const PURCHASE_CODES = ["s"]; // 구매관리 카테고리 코드 setItemSearchResults(allItems.filter((item: any) => { if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false; - const div = item.division || ""; - return div.includes(PURCHASE_CODE) || div.includes("구매"); + const divCodes = (item.division || "").split(",").map((c: string) => c.trim()); + return divCodes.some((code: string) => PURCHASE_CODES.includes(code)); })); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -911,27 +913,27 @@ export default function SupplierManagementPage() { [itemKey]: (prev[itemKey] || []).map((r) => { if (r._id !== rowId) return r; const updated = { ...r, [field]: value }; - if (["base_price", "discount_type", "discount_value", "rounding_unit_value"].includes(field)) { + if (["base_price", "discount_type", "discount_value", "rounding_unit_value", "rounding_type"].includes(field)) { const bp = Number(updated.base_price) || 0; const dv = Number(updated.discount_value) || 0; const dt = updated.discount_type; let calc = bp; if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100); else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv; - // 절삭/반올림 적용 + // 반올림 유형 + 단위 적용 const rv = updated.rounding_unit_value; + const rt = updated.rounding_type; const roundOpts = priceCategoryOptions["rounding_unit_value"] || []; const roundLabel = roundOpts.find((o) => o.code === rv)?.label || ""; - if (roundLabel.includes("절삭") || roundLabel.includes("버림") || roundLabel.includes("floor")) { - calc = Math.floor(calc); - } else if (roundLabel.includes("올림") || roundLabel.includes("ceil")) { - calc = Math.ceil(calc); - } else if (roundLabel.includes("반올림") || roundLabel.includes("round")) { - calc = Math.round(calc); - } else if (rv) { - // 단위 값 기반 (예: 10원 단위 절삭) - const unit = Number(rv); - if (!isNaN(unit) && unit > 0) calc = Math.floor(calc / unit) * unit; + const unitOpts = priceCategoryOptions["rounding_type"] || []; + const unitLabel = unitOpts.find((o) => o.code === rt)?.label || ""; + const unit = parseInt(unitLabel) || 1; // "10원" → 10, "100원" → 100 + if (roundLabel === "반올림") { + calc = Math.round(calc / unit) * unit; + } else if (roundLabel === "절삭") { + calc = Math.floor(calc / unit) * unit; + } else if (roundLabel === "올림") { + calc = Math.ceil(calc / unit) * unit; } updated.calculated_price = String(Math.floor(calc)); } @@ -2370,7 +2372,7 @@ export default function SupplierManagementPage() { 품목 선택 - 거래처에 추가할 품목을 선택하세요. + 공급업체에 추가할 품목을 선택하세요. (전체: {itemTotalCount}건 / 대상: {itemSearchResults.length}건{itemCheckedIds.size > 0 ? ` / 선택: ${itemCheckedIds.size}건` : ""})
- {/* 기준가/할인/반올림 */} -
-
- -
+ {/* 기준유형 + 기준가 */} +
+ { const raw = e.target.value.replace(/[^\d.-]/g, ""); updatePriceRow(itemKey, price._id, "base_price", raw); }} - className="h-9 text-[13px] text-right flex-1" + className="h-9 text-[13px] text-right col-span-3" placeholder="기준가" /> -
- -
+
+ {/* 할인 + 반올림 */} +
+ { const raw = e.target.value.replace(/[^\d.-]/g, ""); updatePriceRow(itemKey, price._id, "discount_value", raw); }} - className="h-9 text-[13px] text-right w-[60px]" + className="h-9 text-[13px] text-right" placeholder="0" /> -
- -
+ +
{/* 계산 단가 */}
diff --git a/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx index 20bde7c4..bddc7730 100644 --- a/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx @@ -127,6 +127,7 @@ export default function CustomerManagementPage() { const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); + const [itemTotalCount, setItemTotalCount] = useState(0); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); @@ -817,12 +818,13 @@ export default function CustomerManagementPage() { autoFilter: true, }); const allItems = res.data?.data?.data || res.data?.data?.rows || []; + setItemTotalCount(allItems.length); const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number)); - const SALES_CODE = "CAT_ML8ZFVEL_1TOR"; + const SALES_CODES = ["CAT_ML8ZFVEL_1TOR"]; // 영업관리 카테고리 코드 setItemSearchResults(allItems.filter((item: any) => { if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false; - const div = item.division || ""; - return div.includes(SALES_CODE) || div.includes("영업"); + const divCodes = (item.division || "").split(",").map((c: string) => c.trim()); + return divCodes.some((code: string) => SALES_CODES.includes(code)); })); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -911,27 +913,27 @@ export default function CustomerManagementPage() { [itemKey]: (prev[itemKey] || []).map((r) => { if (r._id !== rowId) return r; const updated = { ...r, [field]: value }; - if (["base_price", "discount_type", "discount_value", "rounding_unit_value"].includes(field)) { + if (["base_price", "discount_type", "discount_value", "rounding_unit_value", "rounding_type"].includes(field)) { const bp = Number(updated.base_price) || 0; const dv = Number(updated.discount_value) || 0; const dt = updated.discount_type; let calc = bp; if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100); else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv; - // 절삭/반올림 적용 + // 반올림 유형 + 단위 적용 const rv = updated.rounding_unit_value; + const rt = updated.rounding_type; const roundOpts = priceCategoryOptions["rounding_unit_value"] || []; const roundLabel = roundOpts.find((o) => o.code === rv)?.label || ""; - if (roundLabel.includes("절삭") || roundLabel.includes("버림") || roundLabel.includes("floor")) { - calc = Math.floor(calc); - } else if (roundLabel.includes("올림") || roundLabel.includes("ceil")) { - calc = Math.ceil(calc); - } else if (roundLabel.includes("반올림") || roundLabel.includes("round")) { - calc = Math.round(calc); - } else if (rv) { - // 단위 값 기반 (예: 10원 단위 절삭) - const unit = Number(rv); - if (!isNaN(unit) && unit > 0) calc = Math.floor(calc / unit) * unit; + const unitOpts = priceCategoryOptions["rounding_type"] || []; + const unitLabel = unitOpts.find((o) => o.code === rt)?.label || ""; + const unit = parseInt(unitLabel) || 1; // "10원" → 10, "100원" → 100 + if (roundLabel === "반올림") { + calc = Math.round(calc / unit) * unit; + } else if (roundLabel === "절삭") { + calc = Math.floor(calc / unit) * unit; + } else if (roundLabel === "올림") { + calc = Math.ceil(calc / unit) * unit; } updated.calculated_price = String(Math.floor(calc)); } @@ -2368,7 +2370,7 @@ export default function CustomerManagementPage() { 품목 선택 - 거래처에 추가할 품목을 선택하세요. + 거래처에 추가할 품목을 선택하세요. (전체: {itemTotalCount}건 / 대상: {itemSearchResults.length}건{itemCheckedIds.size > 0 ? ` / 선택: ${itemCheckedIds.size}건` : ""})
- {/* 기준가/할인/반올림 */} -
-
- -
+ {/* 기준유형 + 기준가 */} +
+ { const raw = e.target.value.replace(/[^\d.-]/g, ""); updatePriceRow(itemKey, price._id, "base_price", raw); }} - className="h-9 text-[13px] text-right flex-1" + className="h-9 text-[13px] text-right col-span-3" placeholder="기준가" /> -
- -
+
+ {/* 할인 + 반올림 */} +
+ { const raw = e.target.value.replace(/[^\d.-]/g, ""); updatePriceRow(itemKey, price._id, "discount_value", raw); }} - className="h-9 text-[13px] text-right w-[60px]" + className="h-9 text-[13px] text-right" placeholder="0" /> -
- -
+ +
{/* 계산 단가 */}
diff --git a/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx index 3c510588..0a710894 100644 --- a/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx @@ -13,12 +13,13 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { Checkbox } from "@/components/ui/checkbox"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Users, Search, X, Settings2 } from "lucide-react"; +import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Users, Package, Search, X, Settings2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; @@ -55,7 +56,7 @@ const ITEM_GRID_COLUMNS = [ export default function SalesItemPage() { const { user } = useAuth(); - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); const ts = useTableSettings("c16-sales-item", ITEM_TABLE, ITEM_GRID_COLUMNS); // 좌측: 품목 @@ -361,14 +362,25 @@ export default function SalesItemPage() { [custKey]: (prev[custKey] || []).map((r) => { if (r._id !== rowId) return r; const updated = { ...r, [field]: value }; - if (["base_price", "discount_type", "discount_value"].includes(field)) { + if (["base_price", "discount_type", "discount_value", "rounding_unit_value", "rounding_type"].includes(field)) { const bp = Number(updated.base_price) || 0; const dv = Number(updated.discount_value) || 0; const dt = updated.discount_type; let calc = bp; if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100); else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv; - updated.calculated_price = String(Math.round(calc)); + // 반올림 유형 + 단위 적용 + const rv = updated.rounding_unit_value; + const rt = updated.rounding_type; + const roundOpts = priceCategoryOptions["rounding_unit_value"] || []; + const roundLabel = roundOpts.find((o) => o.code === rv)?.label || ""; + const unitOpts = priceCategoryOptions["rounding_type"] || []; + const unitLabel = unitOpts.find((o) => o.code === rt)?.label || ""; + const unit = parseInt(unitLabel) || 1; + if (roundLabel === "반올림") calc = Math.round(calc / unit) * unit; + else if (roundLabel === "절삭") calc = Math.floor(calc / unit) * unit; + else if (roundLabel === "올림") calc = Math.ceil(calc / unit) * unit; + updated.calculated_price = String(Math.floor(calc)); } return updated; }), @@ -619,8 +631,7 @@ export default function SalesItemPage() { ]; return ( -
- +
{/* 검색 필터 (DynamicSearchFilter) */} - - -
- } /> - {/* ── 마스터-디테일 분할 패널 ── */} -
+ {/* 액션 버튼 영역 */} +
+
+ + +
+
+ + {/* 마스터-디테일 분할 패널 */} +
- {/* 좌측: 판매품목 목록 (마스터) */} + {/* 좌측: 판매품목 목록 */} -
+
{/* 패널 헤더 */} -
-
- 판매품목 목록 - +
+
+ 판매품목 목록 + {itemCount}건
- +
+ + +
- {/* 테이블 영역 */} + + {/* 거래처 테이블 */} setSelectedItemId(id)} onRowDoubleClick={() => openEditItem()} showRowNumber - showPagination={false} + showPagination + defaultPageSize={20} draggableColumns={false} columnOrderKey="c16-sales-item" /> @@ -681,69 +701,49 @@ export default function SalesItemPage() { - {/* 우측: 거래처 정보 (디테일) */} + {/* 우측: 디테일 패널 */} -
+
{!selectedItemId ? ( /* 빈 상태 */ -
-
- -
-
품목을 선택해주세요
-
좌측에서 품목을 선택하면 거래처 정보가 표시돼요
-
+
+
+ +
품목을 선택해주세요
+
좌측에서 품목을 선택하면 상세 정보가 표시돼요
) : ( <> - {/* 선택 품목 상세 헤더 */} -
-
- {selectedItem?.item_name || "-"} - - {selectedItem?.item_number || ""} - -
- -
- - {/* 거래처별 단가 서브헤더 */} -
-
- 거래처별 단가 - - {customerItems.length}건 - + {/* 거래처별 단가 헤더 */} +
+
+ + 거래처별 단가 + {customerItems.length > 0 && ( + {customerItems.length} + )}
{/* 거래처 테이블 */} -
+
{customerLoading ? (
@@ -755,8 +755,8 @@ export default function SalesItemPage() { ) : ( - - + + 0 && customerCheckedIds.length === customerItems.length} onCheckedChange={(checked) => { @@ -765,13 +765,13 @@ export default function SalesItemPage() { }} /> - 거래처코드 - 거래처명 - 거래처품번 - 거래처품명 - 기준가 - 단가 - 통화 + 거래처코드 + 거래처명 + 거래처품번 + 거래처품명 + 기준가 + 단가 + 통화 @@ -779,12 +779,20 @@ export default function SalesItemPage() { openEditCust(row)} > - + { + e.stopPropagation(); + setCustomerCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > { @@ -1104,66 +1112,74 @@ export default function SalesItemPage() { - {/* 기준가 + 할인 + 반올림 */} -
-
- -
+ {/* 기준유형 + 기준가 */} +
+ updatePriceRow(custKey, price._id, "base_price", e.target.value)} - className="h-8 text-xs text-right flex-1" + value={price.base_price ? Number(price.base_price).toLocaleString() : ""} + onChange={(e) => { + const raw = e.target.value.replace(/[^\d.-]/g, ""); + updatePriceRow(custKey, price._id, "base_price", raw); + }} + className="h-8 text-xs text-right col-span-3" placeholder="기준가" /> -
- -
+
+ {/* 할인 + 반올림 */} +
+ updatePriceRow(custKey, price._id, "discount_value", e.target.value)} - className="h-8 text-xs text-right w-[60px]" + value={price.discount_value ? Number(price.discount_value).toLocaleString() : ""} + onChange={(e) => { + const raw = e.target.value.replace(/[^\d.-]/g, ""); + updatePriceRow(custKey, price._id, "discount_value", raw); + }} + className="h-8 text-xs text-right" placeholder="0" /> -
- -
+ +
{/* 계산 단가 */}