4267b42fdf
- 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.
2060 lines
102 KiB
TypeScript
2060 lines
102 KiB
TypeScript
"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"> </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>
|
||
);
|
||
}
|