Files
pipeline/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx
T
kjs 4267b42fdf refactor: Streamline logistics pages by removing unused variables and enhancing header filters
- Removed unnecessary variables and commented-out code related to master-detail grouping in the outbound and receiving pages.
- Simplified the header filter and sorting logic to improve performance and readability.
- Updated the column mapping and filtering mechanisms to ensure a more efficient data handling process.
- These changes aim to enhance the overall user experience and maintainability of the logistics management interface across multiple company implementations.
2026-04-13 13:15:28 +09:00

2060 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* 구매품목관리 — Type B 마스터-디테일 리디자인
*
* 좌측: 구매품목 목록 (item_info, 구매 관련 필터)
* 우측: 선택한 품목의 공급업체 정보 (supplier_item_mapping → supplier_mng 조인)
*
* 공급업체관리와 양방향 연동 (같은 supplier_item_mapping 테이블)
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
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 { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { ImageUpload } from "@/components/common/ImageUpload";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Search, X, Settings2, Package,
ChevronRight, ChevronDown, Coins, GripVertical, Check, ChevronsUpDown,
} from "lucide-react";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { exportToExcel } from "@/lib/utils/excelExport";
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";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "supplier_item_mapping";
const SUPPLIER_TABLE = "supplier_mng";
// 검색 가능한 카테고리 콤보박스
function CategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.code === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">{selected?.label || <span className="text-muted-foreground">{placeholder}</span>}</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => { onChange(opt.code); setOpen(false); }}>
<Check className={cn("mr-2 h-3.5 w-3.5", value === opt.code ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 다중 선택 카테고리 콤보박스
function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
options: { code: string; label: string }[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const selectedCodes = value ? value.split(",").map((c) => c.trim()).filter(Boolean) : [];
const selectedLabels = selectedCodes.map((code) => options.find((o) => o.code === code)?.label || code).filter(Boolean);
const toggle = (code: string) => {
const next = selectedCodes.includes(code)
? selectedCodes.filter((c) => c !== code)
: [...selectedCodes, code];
onChange(next.join(","));
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
<span className="truncate">
{selectedLabels.length > 0
? selectedLabels.join(", ")
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="검색..." className="h-8" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{options.map((opt) => (
<CommandItem key={opt.code} value={opt.label} onSelect={() => toggle(opt.code)}>
<Check className={cn("mr-2 h-3.5 w-3.5", selectedCodes.includes(opt.code) ? "opacity-100" : "opacity-0")} />
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "width", label: "가로", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "height", label: "세로", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "thickness", label: "두께", type: "text", placeholder: "숫자 입력 (mm)" },
{ key: "area", label: "면적", type: "text", placeholder: "숫자 입력 (㎡)" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text", placeholder: "숫자 입력 (예: 3.5)" },
{ key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" },
{ key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
{ key: "image", label: "품목 이미지", type: "image" },
{ key: "meno", label: "메모", type: "textarea" },
] as const;
const CATEGORY_COLUMNS = [
"division", "type", "unit", "material", "status",
"inventory_unit", "currency_code", "user_type01", "user_type02",
];
// 숫자 포맷 헬퍼
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: "width", label: "가로" },
{ key: "height", label: "세로" },
{ key: "thickness", label: "두께" },
{ key: "area", label: "면적" },
{ key: "unit", label: "단위" },
{ key: "standard_price", label: "기준단가/구매단가" },
{ key: "currency_code", label: "통화" },
{ key: "status", label: "상태" },
];
function SortableMappingRow({ id, children }: { id: string; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform), transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} className="flex gap-2 items-center">
<div {...attributes} {...listeners} className="cursor-grab text-muted-foreground/40 hover:text-muted-foreground shrink-0">
<GripVertical className="h-3.5 w-3.5" />
</div>
{children}
</div>
);
}
export default function PurchaseItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
const ts = useTableSettings("c16-purchase-item", ITEM_TABLE, ITEM_GRID_COLUMNS);
const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [rawItems, setRawItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 품목 등록/수정 모달 (item-info 스타일)
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 우측: 공급업체
const [supplierItems, setSupplierItems] = useState<any[]>([]);
const [supplierGroups, setSupplierGroups] = useState<Record<string, { master: any; details: any[] }>>({});
const [supplierLoading, setSupplierLoading] = useState(false);
const [supplierCheckedIds, setSupplierCheckedIds] = useState<string[]>([]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [collapsedPriceCards, setCollapsedPriceCards] = useState<Set<string>>(new Set());
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string; isDefault?: boolean }[]>>({});
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 공급업체 추가 모달
const [suppSelectOpen, setSuppSelectOpen] = useState(false);
const [suppSearchKeyword, setSuppSearchKeyword] = useState("");
const [suppSearchResults, setSuppSearchResults] = useState<any[]>([]);
const [suppSearchLoading, setSuppSearchLoading] = useState(false);
const [suppCheckedIds, setSuppCheckedIds] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 공급업체 상세 입력 모달 (공급업체 품번/품명 + 단가)
const [suppDetailOpen, setSuppDetailOpen] = useState(false);
const [selectedSuppsForDetail, setSelectedSuppsForDetail] = useState<any[]>([]);
const [suppMappings, setSuppMappings] = useState<Record<string, Array<{ _id: string; supplier_item_code: string; supplier_item_name: string }>>>({});
const [suppPrices, setSuppPrices] = useState<Record<string, Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}>>>({});
const [editSuppData, setEditSuppData] = useState<any>(null);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string; isDefault?: boolean }[]> = {};
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, isDefault: v.isDefault });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) optMap[col] = flatten(res.data.data);
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
// 단가 카테고리
const priceOpts: Record<string, { code: string; label: string }[]> = {};
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();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters: { columnName: string; operator: string; value: any }[] = [];
// 구매관리 division 필터: 카테고리에서 "구매관리" 라벨의 코드를 찾아서 필터링
const purchaseCode = categoryOptions["division"]?.find((o) => o.label === "구매관리")?.code;
if (purchaseCode) {
filters.push({ columnName: "division", operator: "contains", value: purchaseCode });
}
// 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: 5000,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setRawItems(raw);
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
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, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { fetchItems(); }, [fetchItems]);
// 프리뷰 코드에서 각 파트별 표시값을 추출
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
if (!previewCode || !rule?.parts) return [];
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const globalSep = rule.separator || "";
const partMeta = sorted.map((part: any, idx: number) => {
const sep = idx < sorted.length - 1
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
: "";
const config = part.autoConfig || {};
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
switch (part.partType) {
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
case "date": {
const now = new Date();
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
const fmt = config.dateFormat || "YYYYMMDD";
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
}
default: return { known: false, sep, isManual: false, partType: part.partType };
}
});
let remaining = previewCode;
const results: { value: string; isManual: boolean; separator: string }[] = [];
for (let i = 0; i < partMeta.length; i++) {
const meta = partMeta[i];
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
if (meta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) {
remaining = remaining.substring(markerIdx + 4);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: "", isManual: true, separator: meta.sep });
continue;
}
if (meta.known) {
const valIdx = remaining.indexOf(meta.value);
if (valIdx >= 0) {
remaining = remaining.substring(valIdx + meta.value.length);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: meta.value, isManual: false, separator: meta.sep });
} else {
let endIdx = remaining.length;
if (meta.sep) {
if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const patIdx = remaining.indexOf(meta.sep + nextMeta.value);
if (patIdx >= 0) endIdx = patIdx;
} else if (nextMeta.isManual) {
const patIdx = remaining.indexOf(meta.sep + "____");
if (patIdx >= 0) endIdx = patIdx;
} else {
const sepIdx = remaining.indexOf(meta.sep);
if (sepIdx >= 0) endIdx = sepIdx;
}
}
} else if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const valIdx = remaining.indexOf(nextMeta.value);
if (valIdx >= 0) endIdx = valIdx;
} else if (nextMeta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) endIdx = markerIdx;
}
}
const extracted = remaining.substring(0, endIdx);
remaining = remaining.substring(endIdx);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
results.push({ value: extracted, isManual: false, separator: meta.sep });
}
}
return results;
};
// 파트 값으로부터 전체 코드 조합
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
return parts.map((p, idx) => {
const val = p.isManual ? manualVal : p.value;
const sep = idx < parts.length - 1 ? p.separator : "";
return val + sep;
}).join("");
};
// 채번 미리보기
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
setIsNumberingLoading(true);
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
const parts = parsePreviewIntoParts(generatedCode, rule);
setNumberingParts(parts);
return { code: generatedCode, parts };
} catch { /* 채번 규칙 없으면 무시 */ }
finally {
setIsNumberingLoading(false);
}
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
const result = await loadNumberingPreview({});
if (result.code) {
const hasManual = result.parts.some(p => p.isManual);
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
setFormData(prev => ({ ...prev, item_number: displayCode }));
}
};
// 수정 모달 열기
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(formData, manualInputValue);
if (result.parts.length > 0) {
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingParts.some(p => p.isManual)) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(formData, manualInputValue);
if (result.parts.length > 0) {
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장 (등록 또는 수정)
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력이에요.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
if (numberingRuleIdRef.current) {
try {
const hasManual = numberingParts.some(p => p.isManual);
const userInputCode = hasManual && manualInputValue
? manualInputValue
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 공급업체 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) {
setSupplierItems([]);
setSupplierGroups({});
setSupplierCheckedIds([]);
return;
}
setSupplierCheckedIds([]);
const itemKey = selectedItem.item_number;
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,
sort: { columnName: "created_date", order: "asc" },
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// 2. supplier_id → supplier_mng 조인 (공급업체명)
const custIds = [...new Set(mappings.map((m: any) => m.supplier_id).filter(Boolean))];
let custMap: Record<string, any> = {};
if (custIds.length > 0) {
try {
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 c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) {
custMap[c.supplier_code] = c;
}
} catch { /* skip */ }
}
// 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. 공급업체별 그룹핑 — master: 첫 매핑 + 현재 단가, details: 전체 단가 리스트
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<string>();
const grouped: Record<string, { master: any; details: any[] }> = {};
const flatItems: any[] = [];
for (const m of mappings) {
const custKey = m.supplier_id || "";
if (seenCustIds.has(custKey)) continue; // 공급업체당 첫 매핑만 마스터
seenCustIds.add(custKey);
const custInfo = custMap[custKey] || {};
const custPriceList = allPrices
.filter((p: any) => p.supplier_id === custKey)
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
const todayPrice = custPriceList.find((p: any) =>
(!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today)
) || custPriceList[0] || {};
const masterRow = {
...m,
supplier_code: custKey,
supplier_name: custInfo.supplier_name || "",
supplier_item_code: m.supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "",
base_price_type: priceResolve("base_price_type", todayPrice.base_price_type || ""),
base_price: todayPrice.base_price || "",
discount_type: priceResolve("discount_type", todayPrice.discount_type || ""),
discount_value: todayPrice.discount_value || "",
calculated_price: todayPrice.calculated_price || todayPrice.unit_price || "",
currency_code: priceResolve("currency_code", todayPrice.currency_code || ""),
};
// 단가 리스트 (라벨 변환)
const priceDetails = custPriceList.map((p: any) => ({
...p,
base_price_type_label: priceResolve("base_price_type", p.base_price_type || ""),
discount_type_label: priceResolve("discount_type", p.discount_type || ""),
currency_label: priceResolve("currency_code", p.currency_code || ""),
is_current: (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today),
}));
grouped[custKey] = { master: masterRow, details: priceDetails };
flatItems.push(masterRow);
}
setSupplierGroups(grouped);
setSupplierItems(flatItems);
} catch (err) {
console.error("공급업체 조회 실패:", err);
} finally {
setSupplierLoading(false);
}
};
fetchSupplierItems();
}, [selectedItem?.item_number, priceCategoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps
// 공급업체 검색
const searchSuppliers = useCallback(async () => {
setSuppSearchLoading(true);
try {
const filters: any[] = [];
if (suppSearchKeyword) filters.push({ columnName: "supplier_name", operator: "contains", value: suppSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 공급업체 제외
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); }
}, [suppSearchKeyword, supplierItems]);
// 실시간 검색 (2글자 이상)
useEffect(() => {
if (!suppSelectOpen) return;
if (suppSearchKeyword.length > 0 && suppSearchKeyword.length < 2) return;
searchSuppliers();
}, [suppSearchKeyword, suppSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// 공급업체 선택 → 상세 모달로 이동
const goToSuppDetail = () => {
const selected = suppSearchResults.filter((c) => suppCheckedIds.has(c.id));
if (selected.length === 0) { toast.error("공급업체를 선택해주세요."); return; }
setSelectedSuppsForDetail(selected);
const mappings: typeof suppMappings = {};
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);
setSuppDetailOpen(true);
};
const addMappingRow = (custKey: string) => {
setSuppMappings((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, supplier_item_code: "", supplier_item_name: "" }],
}));
};
const removeMappingRow = (custKey: string, rowId: string) => {
setSuppMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const handleMappingDragEnd = (custKey: string, event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setSuppMappings((prev) => {
const arr = [...(prev[custKey] || [])];
const oldIdx = arr.findIndex((r) => r._id === active.id);
const newIdx = arr.findIndex((r) => r._id === over.id);
return { ...prev, [custKey]: arrayMove(arr, oldIdx, newIdx) };
});
};
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 */ }
// 매핑 전체 조회
let mappingRows: any[] = [];
try {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
.filter((m: any) => m.supplier_item_code || m.supplier_item_name)
.map((m: any) => ({
_id: `m_existing_${m.id}`,
supplier_item_code: m.supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "",
}));
} catch { /* skip */ }
// 단가 전체 조회
let priceRows: any[] = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
});
const allPriceData = (priceRes.data?.data?.data || priceRes.data?.data?.rows || [])
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
end_date: p.end_date ? String(p.end_date).split("T")[0] : "",
currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: p.base_price ? String(p.base_price) : "",
discount_type: p.discount_type || "",
discount_value: p.discount_value ? String(p.discount_value) : "",
rounding_type: p.rounding_type || "",
rounding_unit_value: p.rounding_unit_value || "",
calculated_price: p.calculated_price ? String(p.calculated_price) : "",
}));
} catch { /* skip */ }
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 isEditingExisting = !!editSuppData;
setSaving(true);
try {
for (const cust of selectedSuppsForDetail) {
const custKey = cust.supplier_code || cust.id;
const mappingRows = suppMappings[custKey] || [];
if (isEditingExisting && editSuppData?.id) {
// 기존 매핑 조회
let existingMaps: any[] = [];
try {
const existingMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
sort: { columnName: "created_date", order: "asc" },
});
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
} catch { /* skip */ }
// 매핑 upsert: 인덱스 기반
const usedExistingIds = new Set<string>();
let firstMappingId: string | null = editSuppData.id;
for (let mi = 0; mi < mappingRows.length; mi++) {
const existMap = existingMaps[mi];
if (existMap) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: existMap.id },
updatedData: {
supplier_item_code: mappingRows[mi].supplier_item_code || "",
supplier_item_name: mappingRows[mi].supplier_item_name || "",
},
});
usedExistingIds.add(existMap.id);
if (mi === 0) firstMappingId = existMap.id;
} else {
const mRes = 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 || "",
});
if (mi === 0 && !firstMappingId) firstMappingId = mRes.data?.data?.id || null;
}
}
// 초과분 delete
const toDeleteMaps = existingMaps.filter((m) => !usedExistingIds.has(m.id));
if (toDeleteMaps.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDeleteMaps.map((m: any) => ({ id: m.id })),
});
}
// 기존 단가 조회
let existingPriceRows: any[] = [];
try {
const existingPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
} catch { /* skip */ }
// 단가 upsert: 인덱스 기반
const priceRows = (suppPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
const price = priceRows[pi];
const priceData = {
mapping_id: firstMappingId || 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,
unit_price: price.calculated_price ? Number(price.calculated_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,
};
const existPrice = existingPriceRows[pi];
if (existPrice) {
await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, {
originalData: { id: existPrice.id },
updatedData: priceData,
});
usedPriceIds.add(existPrice.id);
} else {
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
id: crypto.randomUUID(), ...priceData,
});
}
}
// 초과분 delete
const toDeletePrices = existingPriceRows.filter((p) => !usedPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
}
} else {
// 신규 등록
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 || p.start_date || p.currency_code || p.base_price_type
);
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,
unit_price: price.calculated_price ? Number(price.calculated_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(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);
}
};
// 우측: 공급업체 매핑 해제 (소프트 삭제 — item_id를 null 처리)
const handleSupplierMappingDelete = async () => {
if (supplierCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체의 연결을 해제하시겠습니까?`, {
description: "해당 공급업체의 품목 연결이 해제됩니다. (데이터는 유지)",
variant: "destructive", confirmText: "해제",
});
if (!ok) return;
try {
const supplierCodes = supplierCheckedIds.map((mid) => {
const group = Object.values(supplierGroups).find((g) => g.master.id === mid);
return group?.master.supplier_id || group?.master.supplier_code || "";
}).filter(Boolean);
for (const suppCode of supplierCodes) {
// 해당 공급업체의 모든 매핑 조회 → item_id null 처리
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: selectedItem!.item_number },
{ columnName: "supplier_id", operator: "equals", value: suppCode },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
for (const m of allMappings) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: m.id },
updatedData: { item_id: null },
});
}
// 해당 공급업체의 모든 단가 조회 → item_id null 처리
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: selectedItem!.item_number },
{ columnName: "supplier_id", operator: "equals", value: suppCode },
]}, autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
for (const p of prices) {
await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, {
originalData: { id: p.id },
updatedData: { item_id: null },
});
}
} catch { /* skip */ }
}
toast.success(`${supplierCheckedIds.length}개 공급업체의 연결이 해제되었습니다.`);
setSupplierCheckedIds([]);
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch {
toast.error("연결 해제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 구매단가: i.standard_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "구매품목관리.xlsx", "구매품목");
toast.success("다운로드 완료");
};
// EDataTable 컬럼 정의 (구매품목) — ts.visibleColumns 기반
const COLUMN_RENDER_MAP: Record<string, Partial<EDataTableColumn>> = {
item_number: { width: "w-[110px]" },
item_name: { minWidth: "min-w-[130px]" },
size: { width: "w-[80px]" },
unit: { width: "w-[60px]" },
standard_price: { width: "w-[90px]", align: "right", formatNumber: true },
currency_code: { width: "w-[50px]" },
status: { width: "w-[60px]" },
};
const itemColumns: EDataTableColumn[] = ts.visibleColumns.map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
...COLUMN_RENDER_MAP[col.key],
}));
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 필터 (DynamicSearchFilter) */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="c16-purchase-item"
onFilterChange={setSearchFilters}
dataCount={items.length}
externalFilterConfig={ts.filterConfig}
/>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-2 px-4 shrink-0">
<div className="flex gap-1.5 ml-auto">
<Button
variant="outline" size="sm" className="h-8"
disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const result = await autoDetectMultiTableConfig(ITEM_TABLE);
if (result.success && result.data) {
setExcelChainConfig(result.data);
setExcelUploadOpen(true);
} else {
toast.error(result.message || "테이블 구조 분석 실패");
}
} catch { toast.error("엑셀 업로드 설정 실패"); } finally { setExcelDetecting(false); }
}}
>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{/* 마스터-디테일 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 구매품목 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 h-[42px] border-b bg-muted shrink-0">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-bold"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
{itemCount}
</span>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={() => {
const item = items.find((i) => i.id === selectedItemId);
if (item) openEditModal(item);
}}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* 거래처 테이블 */}
<EDataTable
columns={itemColumns}
data={ts.groupData(items)}
rowKey={(row) => row.id}
loading={itemLoading}
emptyMessage="등록된 구매품목이 없어요"
selectedId={selectedItemId}
onSelect={(id) => setSelectedItemId(id)}
onRowDoubleClick={(row) => openEditModal(row)}
showRowNumber
showPagination
defaultPageSize={20}
draggableColumns={false}
columnOrderKey="c16-purchase-item"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 디테일 패널 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
{!selectedItemId ? (
/* 빈 상태 */
<div className="flex-1 flex items-center justify-center p-5">
<div className="flex flex-col items-center justify-center text-center border-2 border-dashed border-border rounded-lg px-10 py-16">
<Package className="w-12 h-12 text-muted-foreground/40 mb-4" />
<div className="text-sm font-semibold text-muted-foreground mb-1.5"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
</div>
) : (
<>
{/* 공급업체별 단가 헤더 */}
<div className="flex items-center justify-between h-[42px] border-b bg-muted shrink-0 px-4">
<div className="flex items-center gap-2.5">
<Package className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[13px] font-bold"> </span>
{Object.keys(supplierGroups).length > 0 && (
<Badge variant="secondary" className="ml-0.5 text-[10px] px-1.5 py-0">
{Object.keys(supplierGroups).length}
</Badge>
)}
</div>
<div className="flex gap-1.5">
<Button
size="sm"
onClick={() => { setSuppCheckedIds(new Set()); setSuppSelectOpen(true); searchSuppliers(); }}
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="destructive"
size="sm"
disabled={supplierCheckedIds.length === 0}
onClick={handleSupplierMappingDelete}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* 공급업체 테이블 (expandable rows) */}
<div className="flex-1 min-h-0 overflow-auto pt-px">
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted h-10">
<TableHead className="w-[40px] text-center px-2">
<input
type="checkbox"
className="rounded"
checked={supplierItems.length > 0 && supplierCheckedIds.length === supplierItems.length}
onChange={(e) => {
if (e.target.checked) setSupplierCheckedIds(supplierItems.map((c) => c.id));
else setSupplierCheckedIds([]);
}}
/>
</TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{supplierLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
</TableCell>
</TableRow>
) : Object.keys(supplierGroups).length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground text-sm">
</TableCell>
</TableRow>
) : Object.entries(supplierGroups).map(([custKey, group]) => {
const isExpanded = expandedItems.has(custKey);
const m = group.master;
const isChecked = supplierCheckedIds.includes(m.id);
return (
<React.Fragment key={custKey}>
{/* 마스터 행 */}
<TableRow
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent h-[41px]",
isChecked && "bg-primary/5 border-l-primary"
)}
onClick={() => {
setExpandedItems((prev) => {
const next = new Set(prev);
if (next.has(custKey)) next.delete(custKey); else next.add(custKey);
return next;
});
}}
onDoubleClick={() => openEditSupp(m)}
>
<TableCell
className="text-center px-2"
onClick={(e) => {
e.stopPropagation();
setSupplierCheckedIds((prev) =>
prev.includes(m.id) ? prev.filter((id) => id !== m.id) : [...prev, m.id]
);
}}
>
<input type="checkbox" className="rounded" checked={isChecked} readOnly />
</TableCell>
<TableCell className="text-[13px] font-mono">
<div className="flex items-center gap-1">
{isExpanded
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
}
{m.supplier_code}
</div>
</TableCell>
<TableCell className="text-[13px]">{m.supplier_name}</TableCell>
<TableCell className="text-[13px]">{m.supplier_item_code}</TableCell>
<TableCell className="text-[13px]">{m.supplier_item_name}</TableCell>
<TableCell className="text-[13px] text-right">
{m.base_price ? Number(m.base_price).toLocaleString() : ""}
</TableCell>
<TableCell className="text-[13px] text-right font-semibold">
{m.calculated_price ? Number(m.calculated_price).toLocaleString() : ""}
</TableCell>
<TableCell className="text-[13px]">{m.currency_code}</TableCell>
</TableRow>
{/* 현재 단가 카드 (펼쳤을 때) */}
{isExpanded && (() => {
const cp = group.details.find((p) => p.is_current) || group.details[0];
if (!cp) return (
<TableRow className="border-l-[3px] border-l-primary/30">
<TableCell colSpan={8} className="py-3 px-4 text-xs text-muted-foreground"> </TableCell>
</TableRow>
);
return (
<TableRow className="border-l-[3px] border-l-primary/30">
<TableCell colSpan={8} className="px-4 py-3">
<div className="border border-primary/20 rounded-lg bg-card overflow-hidden">
{/* 카드 헤더 */}
<div className="flex items-center justify-between px-4 py-2 bg-primary/[0.04] border-b border-primary/10">
<div className="flex items-center gap-2">
<Coins className="w-3.5 h-3.5 text-primary" />
<span className="text-xs font-semibold"> </span>
<Badge variant="secondary" className="text-[9px] px-1.5 py-0 bg-primary/10 text-primary"></Badge>
</div>
{group.details.length > 1 && (
<span className="text-[10px] text-muted-foreground"> {group.details.length} </span>
)}
</div>
{/* 카드 내용 */}
<div className="px-5 py-3.5 flex items-end gap-0 text-[13px]">
<div className="flex flex-col pr-5 border-r border-border/30">
<span className="text-[10px] text-muted-foreground/50 font-medium mb-1"></span>
<span className="font-mono text-muted-foreground text-xs">
{cp.start_date ? String(cp.start_date).split("T")[0] : "—"} ~ {cp.end_date ? String(cp.end_date).split("T")[0] : "—"}
</span>
</div>
<div className="flex flex-col px-5 border-r border-border/30">
<span className="text-[10px] text-muted-foreground/50 font-medium mb-1"></span>
<span>{cp.base_price_type_label || "-"}</span>
</div>
<div className="flex flex-col px-5 border-r border-border/30">
<span className="text-[10px] text-muted-foreground/50 font-medium mb-1"></span>
<span className="font-mono font-medium">{cp.base_price ? Number(cp.base_price).toLocaleString() : "-"}</span>
</div>
<div className="flex flex-col px-5 border-r border-border/30">
<span className="text-[10px] text-muted-foreground/50 font-medium mb-1"></span>
<span>{cp.discount_type_label && cp.discount_type_label !== "할인없음" ? cp.discount_type_label : "-"}</span>
</div>
<div className="flex flex-col px-5 border-r border-border/30">
<span className="text-[10px] text-muted-foreground/50 font-medium mb-1"></span>
<span className="font-mono">{cp.discount_value ? Number(cp.discount_value).toLocaleString() : "-"}</span>
</div>
<div className="flex flex-col px-5">
<span className="text-[10px] text-muted-foreground/50 font-medium mb-1"></span>
<span>
{cp.rounding_unit_value
? (priceCategoryOptions["rounding_unit_value"]?.find((o) => o.code === cp.rounding_unit_value)?.label || cp.rounding_unit_value)
: "-"}
</span>
</div>
<span className="text-primary/50 px-4 pb-0.5 text-lg"></span>
<div className="flex flex-col pl-1">
<span className="text-[10px] text-muted-foreground/50 font-medium mb-1"></span>
<span className="text-base font-bold font-mono text-foreground">
{(cp.calculated_price || cp.unit_price) ? Number(cp.calculated_price || cp.unit_price).toLocaleString() : "-"}
<span className="text-xs font-normal text-muted-foreground ml-1.5">{cp.currency_label}</span>
</span>
</div>
</div>
</div>
</TableCell>
</TableRow>
);
})()}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ── 품목 등록/수정 모달 ── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정해요." : "새로운 품목을 등록해요."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4 p-6">
{FORM_FIELDS.map((field) => (
<div
key={field.key}
className={cn("space-y-1.5", (field.type === "textarea" || field.type === "image") && "col-span-2")}
>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{field.label}
{"required" in field && field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "numbering" ? (
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading && numberingParts.length === 0 ? (
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : numberingParts.some(p => p.isManual) ? (
<div className="flex h-9 items-center rounded-md border border-input">
{numberingParts.map((part, idx) => {
const isFirst = idx === 0;
const isLast = idx === numberingParts.length - 1;
if (part.isManual) {
return (
<React.Fragment key={idx}>
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const val = e.target.value;
setManualInputValue(val);
setFormData(prev => ({
...prev,
item_number: buildCodeFromParts(numberingParts, val),
}));
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
/>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
}
return (
<React.Fragment key={idx}>
<span className={cn(
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
isFirst && "rounded-l-[5px]",
isLast && "rounded-r-[5px]",
)}>
{part.value}
</span>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
})}
</div>
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : field.type === "image" ? (
<ImageUpload
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
tableName={ITEM_TABLE}
recordId={formData.id || ""}
columnName={field.key}
height="h-32"
/>
) : field.type === "multi-category" ? (
<MultiCategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "category" ? (
<CategoryCombobox
options={categoryOptions[field.key] || []}
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
placeholder={`${field.label} 선택`}
/>
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : ["selling_price", "standard_price"].includes(field.key) ? (
<Input
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
onChange={(e) => {
const raw = e.target.value.replace(/[^\d.-]/g, "");
setFormData((prev) => ({ ...prev, [field.key]: raw }));
}}
placeholder={"placeholder" in field ? field.placeholder : field.label}
className="h-9 text-right"
/>
) : (
<Input
value={formData[field.key] || ""}
readOnly={field.key === "area"}
onChange={(e) => {
if (field.key === "area") return;
const v = e.target.value;
setFormData((prev) => {
const next = { ...prev, [field.key]: v };
// 가로/세로 변경 시 면적(㎡) 자동 계산: (가로mm × 세로mm) / 1,000,000
if (field.key === "width" || field.key === "height") {
const w = Number(field.key === "width" ? v : prev.width);
const h = Number(field.key === "height" ? v : prev.height);
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
next.area = ((w * h) / 1_000_000).toFixed(4);
}
}
return next;
});
}}
placeholder={field.key === "area" ? "자동 계산" : ("placeholder" in field ? field.placeholder : field.label)}
className={cn("h-9", field.key === "area" && "bg-muted cursor-not-allowed")}
/>
)}
</div>
))}
</div>
</div>
<DialogFooter className="shrink-0 border-t px-6 py-3">
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving
? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
: <Save className="w-4 h-4 mr-1.5" />
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 공급업체 검색 및 추가 모달 ── */}
<Dialog open={suppSelectOpen} onOpenChange={setSuppSelectOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
{/* 검색바 */}
<div className="flex gap-2">
<Input
placeholder="공급업체명으로 검색해주세요"
value={suppSearchKeyword}
onChange={(e) => setSuppSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSuppliers()}
className="h-9 flex-1"
/>
<Button size="sm" onClick={searchSuppliers} disabled={suppSearchLoading} className="h-9">
{suppSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4" /> </>}
</Button>
</div>
{/* 검색 결과 테이블 */}
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table noWrapper>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="sticky top-0 bg-card w-[40px] text-center">
<Checkbox
checked={suppSearchResults.length > 0 && suppCheckedIds.size === suppSearchResults.length}
onCheckedChange={(checked) => {
if (checked === true) setSuppCheckedIds(new Set(suppSearchResults.map((c) => c.id)));
else setSuppCheckedIds(new Set());
}}
/>
</TableHead>
<TableHead className="sticky top-0 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[110px]"></TableHead>
<TableHead className="sticky top-0 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground min-w-[130px]"></TableHead>
<TableHead className="sticky top-0 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[80px]"></TableHead>
<TableHead className="sticky top-0 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suppSearchResults.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8 text-sm">
</TableCell>
</TableRow>
) : suppSearchResults.map((c) => (
<TableRow
key={c.id}
className={cn("cursor-pointer", suppCheckedIds.has(c.id) && "bg-primary/[0.08]")}
onClick={() => setSuppCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(c.id)) next.delete(c.id); else next.add(c.id);
return next;
})}
>
<TableCell className="text-center">
<Checkbox
checked={suppCheckedIds.has(c.id)}
onCheckedChange={(checked) => {
setSuppCheckedIds((prev) => {
const next = new Set(prev);
if (checked === true) next.add(c.id); else next.delete(c.id);
return next;
});
}}
/>
</TableCell>
<TableCell className="text-[13px] font-mono text-muted-foreground">{c.supplier_code}</TableCell>
<TableCell className="text-sm font-medium text-foreground">{c.supplier_name}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{c.division}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{c.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{suppCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSuppSelectOpen(false)}></Button>
<Button onClick={goToSuppDetail} disabled={suppCheckedIds.size === 0}>
<Plus className="w-4 h-4" />
{suppCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 공급업체 상세 입력/수정 모달 ── */}
<Dialog open={suppDetailOpen} onOpenChange={setSuppDetailOpen}>
<DialogContent className="max-w-[1100px] h-[85vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{editSuppData ? "수정" : "입력"} {selectedItem?.item_name || ""}
</DialogTitle>
<DialogDescription>
{editSuppData ? "공급업체 품번/품명과 기간별 단가를 수정해주세요." : "선택한 공급업체의 품번/품명과 기간별 단가를 설정해주세요."}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2 flex-1 overflow-y-auto">
{selectedSuppsForDetail.map((cust, idx) => {
const custKey = cust.supplier_code || cust.id;
const mappingRows = suppMappings[custKey] || [];
const prices = suppPrices[custKey] || [];
return (
<div key={custKey} className="border rounded-lg overflow-hidden">
{/* 공급업체 헤더 */}
<div className="flex items-center gap-2.5 px-4 py-3 bg-muted border-b">
<span className="text-[13px] font-bold text-foreground">{idx + 1}. {cust.supplier_name || custKey}</span>
<span className="text-[11px] font-mono text-muted-foreground bg-muted-foreground/10 px-2 py-0.5 rounded-full">
{custKey}
</span>
</div>
<div className="flex gap-4 p-4 bg-card">
{/* 좌: 공급업체 품번/품명 */}
<div className="flex-1 border-2 border-foreground/20 rounded-lg p-4 flex flex-col">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-sm font-semibold text-foreground"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="border-2 border-foreground/20 rounded-lg bg-background p-3 space-y-2 min-h-[200px] max-h-[350px] overflow-y-auto flex-1">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : (
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleMappingDragEnd(custKey, e)}
>
<SortableContext items={mappingRows.map((r) => r._id)} strategy={verticalListSortingStrategy}>
{mappingRows.map((mRow, mIdx) => (
<SortableMappingRow key={mRow._id} id={mRow._id}>
<span className="text-xs text-muted-foreground/50 font-mono w-4 shrink-0 text-center">{mIdx + 1}</span>
<Input
value={mRow.supplier_item_code}
onChange={(e) => updateMappingRow(custKey, mRow._id, "supplier_item_code", e.target.value)}
placeholder="공급업체 품번"
className="h-9 text-[13px] flex-1"
/>
<Input
value={mRow.supplier_item_name}
onChange={(e) => updateMappingRow(custKey, mRow._id, "supplier_item_name", e.target.value)}
placeholder="공급업체 품명"
className="h-9 text-[13px] flex-1"
/>
<Button
variant="ghost" size="sm"
className="h-7 w-7 p-0 text-destructive shrink-0"
onClick={() => removeMappingRow(custKey, mRow._id)}
>
<X className="h-3 w-3" />
</Button>
</SortableMappingRow>
))}
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border-2 border-foreground/20 rounded-lg p-4 flex flex-col">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-sm font-semibold flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-primary" />
</span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3 flex-1 overflow-y-auto max-h-[350px]">
{prices.map((price, pIdx) => (
<div key={price._id} className="border-2 border-foreground/20 rounded-lg bg-background overflow-hidden">
<div
className="flex items-center justify-between px-4 py-2.5 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setCollapsedPriceCards((prev) => {
const next = new Set(prev);
if (next.has(price._id)) next.delete(price._id); else next.add(price._id);
return next;
})}
>
<div className="flex items-center gap-2">
{collapsedPriceCards.has(price._id)
? <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
}
<span className="text-[13px] font-semibold text-muted-foreground"> {pIdx + 1}</span>
{collapsedPriceCards.has(price._id) && price.calculated_price && (
<span className="text-xs text-muted-foreground ml-2">
{price.start_date || "—"} ~ {price.end_date || "—"} · <span className="font-mono font-bold text-foreground">{Number(price.calculated_price).toLocaleString()}</span> {priceCategoryOptions["currency_code"]?.find((o) => o.code === price.currency_code)?.label || ""}
</span>
)}
</div>
<div className="flex items-center gap-1">
{prices.length > 1 && (
<Button
variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
onClick={(e) => { e.stopPropagation(); removePriceRow(custKey, price._id); }}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{!collapsedPriceCards.has(price._id) && <div className="px-4 pb-4 space-y-2.5">
{/* 기간 + 통화 */}
<div className="flex gap-2 items-center">
<div className="flex-1">
<Label className="text-[11px] text-muted-foreground mb-1 block"></Label>
<Input
type="date"
value={price.start_date}
onChange={(e) => {
const v = e.target.value;
updatePriceRow(custKey, price._id, "start_date", v);
if (price.end_date && v > price.end_date) {
updatePriceRow(custKey, price._id, "end_date", v);
}
}}
max={price.end_date || undefined}
className="h-9 text-[13px] w-full"
/>
</div>
<span className="text-xs text-muted-foreground mt-4">~</span>
<div className="flex-1">
<Label className="text-[11px] text-muted-foreground mb-1 block"></Label>
<Input
type="date"
value={price.end_date}
onChange={(e) => updatePriceRow(custKey, price._id, "end_date", e.target.value)}
min={price.start_date || undefined}
className="h-9 text-[13px] w-full"
/>
</div>
<div className="w-[80px]">
<Label className="text-[11px] text-muted-foreground mb-1 block">&nbsp;</Label>
<Select
value={price.currency_code}
onValueChange={(v) => updatePriceRow(custKey, price._id, "currency_code", v)}
>
<SelectTrigger className="h-9 text-[13px]"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 기준유형 + 기준가 */}
<div className="grid grid-cols-[1fr_70px_1fr_85px] gap-2 items-center">
<Select
value={price.base_price_type}
onValueChange={(v) => updatePriceRow(custKey, price._id, "base_price_type", v)}
>
<SelectTrigger className="h-9 text-[13px]"><SelectValue placeholder="기준유형" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
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-9 text-[13px] text-right col-span-3"
placeholder="기준가"
/>
</div>
{/* 할인 + 반올림 */}
<div className="grid grid-cols-[1fr_70px_1fr_85px] gap-2 items-center">
<Select
value={price.discount_type}
onValueChange={(v) => updatePriceRow(custKey, price._id, "discount_type", v)}
>
<SelectTrigger className="h-9 text-[13px]"><SelectValue placeholder="할인" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
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-9 text-[13px] text-right"
placeholder="0"
/>
<Select
value={price.rounding_unit_value}
onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_unit_value", v)}
>
<SelectTrigger className="h-9 text-[13px]"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={price.rounding_type}
onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_type", v)}
>
<SelectTrigger className="h-9 text-[13px]"><SelectValue placeholder="단위" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 계산 단가 */}
<div className="flex items-center justify-end gap-1.5 pt-2 border-t">
<span className="text-[13px] text-muted-foreground"> :</span>
<span className="font-bold text-base font-mono">
{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}
</span>
{price.calculated_price && price.currency_code && (
<span className="text-[13px] text-muted-foreground">
{priceCategoryOptions["currency_code"]?.find((o) => o.code === price.currency_code)?.label || ""}
</span>
)}
</div>
</div>}
</div>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setSuppDetailOpen(false);
if (!editSuppData) setSuppSelectOpen(true);
setEditSuppData(null);
}}
>
{editSuppData ? "취소" : "← 이전"}
</Button>
<Button onClick={handleSuppDetailSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal
open={excelUploadOpen}
onOpenChange={(open) => {
setExcelUploadOpen(open);
if (!open) setExcelChainConfig(null);
}}
config={excelChainConfig}
onSuccess={() => {
fetchItems();
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
}}
/>
)}
{ConfirmDialogComponent}
</div>
);
}