1709ba6fbb
- Added searchable category combobox and multi-category combobox components to improve item selection in the purchase and sales item pages. - Updated the supplier management page to utilize useCallback for item search, enhancing performance and responsiveness. - Implemented real-time search functionality for item selection, ensuring a smoother user experience. - Enhanced the handling of item mappings and prices, allowing for soft deletion of supplier connections while retaining data integrity. These changes aim to improve the overall user experience by providing more intuitive item management and selection processes across multiple company implementations.
2801 lines
150 KiB
TypeScript
2801 lines
150 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 공급업체관리 — Type B 마스터-디테일 레이아웃 (리디자인)
|
|
*
|
|
* 좌측: 공급업체 목록 (supplier_mng)
|
|
* 우측: 품목별 단가 + 납품처 정보 탭
|
|
*
|
|
* 모달:
|
|
* - 공급업체 등록/수정 (supplier_mng)
|
|
* - 품목 추가 (item_info 검색 → supplier_item_mapping + supplier_item_prices)
|
|
* - 납품처 등록 (delivery_destination)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
import { Label } from "@/components/ui/label";
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
|
Users, Package, MapPin, Search, X, Tag, Coins, Settings2, GripVertical,
|
|
ChevronRight, ChevronDown,
|
|
} 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 { validateField, validateForm, formatField } from "@/lib/utils/validation";
|
|
import { getAvailableNumberingRulesForScreen, previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
|
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 SUPPLIER_TABLE = "supplier_mng";
|
|
const MAPPING_TABLE = "supplier_item_mapping";
|
|
const PRICE_TABLE = "supplier_item_prices";
|
|
const DELIVERY_TABLE = "delivery_destination";
|
|
const CONTACT_TABLE = "supplier_contact";
|
|
|
|
const SUPPLIER_GRID_COLUMNS = [
|
|
{ key: "supplier_code", label: "공급업체코드" },
|
|
{ key: "supplier_name", label: "공급업체명" },
|
|
{ key: "division", label: "공급업체유형" },
|
|
{ key: "contact_person", label: "담당자" },
|
|
{ key: "contact_phone", label: "전화번호" },
|
|
{ key: "email", label: "이메일" },
|
|
{ key: "business_number", label: "사업자번호" },
|
|
{ key: "address", 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 SupplierManagementPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
|
|
const ts = useTableSettings("c16-supplier", SUPPLIER_TABLE, SUPPLIER_GRID_COLUMNS);
|
|
const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
|
|
// 검색 필터 (DynamicSearchFilter에서 관리)
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
// 좌측: 공급업체 목록
|
|
const [suppliers, setSuppliers] = useState<any[]>([]);
|
|
const [rawSuppliers, setRawSuppliers] = useState<any[]>([]);
|
|
const [supplierLoading, setSupplierLoading] = useState(false);
|
|
const [showInactive, setShowInactive] = useState(false);
|
|
const [mainContactMap, setMainContactMap] = useState<Record<string, any>>({});
|
|
const [supplierCount, setSupplierCount] = useState(0);
|
|
const [selectedSupplierId, setSelectedSupplierId] = useState<string | null>(null);
|
|
|
|
// 우측: 탭
|
|
const [rightTab, setRightTab] = useState<"items" | "delivery">("items");
|
|
// 우측: 품목 단가
|
|
const [priceItems, setPriceItems] = useState<any[]>([]);
|
|
const [priceGroups, setPriceGroups] = useState<Record<string, { master: any; details: any[] }>>({});
|
|
const [priceLoading, setPriceLoading] = useState(false);
|
|
const [priceCheckedIds, setPriceCheckedIds] = useState<string[]>([]);
|
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
|
const [collapsedPriceCards, setCollapsedPriceCards] = useState<Set<string>>(new Set());
|
|
// 우측: 납품처
|
|
const [deliveryItems, setDeliveryItems] = useState<any[]>([]);
|
|
const [deliveryLoading, setDeliveryLoading] = useState(false);
|
|
|
|
// 품목 편집 데이터 (더블클릭 시 상세 입력 모달 재활용)
|
|
const [editItemData, setEditItemData] = useState<any>(null);
|
|
const savingRef = useRef(false);
|
|
|
|
// 거래처 모달
|
|
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
|
|
const [supplierEditMode, setSupplierEditMode] = useState(false);
|
|
const [supplierForm, setSupplierForm] = useState<Record<string, any>>({});
|
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 품목 추가 모달 (1단계: 검색/선택)
|
|
const [itemSelectOpen, setItemSelectOpen] = useState(false);
|
|
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
|
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
|
const [itemTotalCount, setItemTotalCount] = useState(0);
|
|
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
|
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
|
|
|
|
// 품목 상세 입력 모달 (2단계: 거래처 품번/품명 + 단가)
|
|
const [itemDetailOpen, setItemDetailOpen] = useState(false);
|
|
const [selectedItemsForDetail, setSelectedItemsForDetail] = useState<any[]>([]);
|
|
const [itemMappings, setItemMappings] = useState<Record<string, Array<{ _id: string; supplier_item_code: string; supplier_item_name: string }>>>({});
|
|
const [itemPrices, setItemPrices] = 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 [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
|
|
|
// 거래처 모달 탭
|
|
const [supplierModalTab, setSupplierModalTab] = useState<"basic" | "contacts" | "delivery">("basic");
|
|
// 담당자 (supplier_contact) - 모달 내
|
|
const [modalContacts, setModalContacts] = useState<any[]>([]);
|
|
const [modalContactLoading, setModalContactLoading] = useState(false);
|
|
const [modalContactForm, setModalContactForm] = useState<Record<string, any>>({});
|
|
const [modalContactEditId, setModalContactEditId] = useState<string | null>(null);
|
|
const [modalContactFormOpen, setModalContactFormOpen] = useState(false);
|
|
const [modalContactSaving, setModalContactSaving] = useState(false);
|
|
// 납품처 (delivery_destination) - 모달 내
|
|
const [modalDeliveries, setModalDeliveries] = useState<any[]>([]);
|
|
const [modalDeliveryLoading, setModalDeliveryLoading] = useState(false);
|
|
const [modalDeliveryForm, setModalDeliveryForm] = useState<Record<string, any>>({});
|
|
const [modalDeliveryEditId, setModalDeliveryEditId] = useState<string | null>(null);
|
|
const [modalDeliveryFormOpen, setModalDeliveryFormOpen] = useState(false);
|
|
const [modalDeliverySaving, setModalDeliverySaving] = useState(false);
|
|
const [modalDeliveryFormErrors, setModalDeliveryFormErrors] = useState<Record<string, string>>({});
|
|
|
|
const [continuousInput, setContinuousInput] = useState(false);
|
|
|
|
// 세금유형 (기본정보 탭 내)
|
|
const [taxTypeRows, setTaxTypeRows] = useState<{ _id: string; tax_type_name: string; rate: string }[]>([]);
|
|
const [taxTypeOptions, setTaxTypeOptions] = useState<{ code: string; label: string }[]>([]);
|
|
|
|
// 엑셀
|
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
|
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
|
|
const [excelDetecting, setExcelDetecting] = useState(false);
|
|
|
|
// 카테고리
|
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
|
const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]);
|
|
|
|
|
|
// 카테고리 로드
|
|
useEffect(() => {
|
|
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
|
const result: { code: string; label: string }[] = [];
|
|
for (const v of vals) {
|
|
result.push({ code: v.valueCode, label: v.valueLabel });
|
|
if (v.children?.length) result.push(...flatten(v.children));
|
|
}
|
|
return result;
|
|
};
|
|
const load = async () => {
|
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
|
for (const col of ["division", "status"]) {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/${SUPPLIER_TABLE}/${col}/values`);
|
|
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
|
} catch { /* skip */ }
|
|
}
|
|
for (const col of ["division", "unit", "material"]) {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
|
if (res.data?.success) optMap[`item_${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/${PRICE_TABLE}/${col}/values`);
|
|
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
|
|
} catch { /* skip */ }
|
|
}
|
|
setPriceCategoryOptions(priceOpts);
|
|
|
|
// 세금유형 카테고리
|
|
try {
|
|
const taxRes = await apiClient.get(`/table-categories/supplier_tax_type/tax_type_name/values`);
|
|
if (taxRes.data?.success) setTaxTypeOptions(flatten(taxRes.data.data || []));
|
|
} catch { /* skip */ }
|
|
};
|
|
load();
|
|
apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true })
|
|
.then((res) => {
|
|
const users = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setEmployeeOptions(users.map((u: any) => ({
|
|
user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name,
|
|
})));
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
// 공급업체 목록 조회
|
|
const fetchSuppliers = useCallback(async () => {
|
|
setSupplierLoading(true);
|
|
try {
|
|
const filters = searchFilters.map(f => ({
|
|
columnName: f.columnName,
|
|
operator: f.operator,
|
|
value: f.value,
|
|
}));
|
|
|
|
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
sort: { columnName: "supplier_code", order: "desc" },
|
|
});
|
|
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setRawSuppliers(raw);
|
|
|
|
const resolve = (col: string, code: string) => {
|
|
if (!code) return "";
|
|
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
|
};
|
|
const data = raw.map((r: any) => {
|
|
const mainContact = mainContactMap[r.id];
|
|
return {
|
|
...r,
|
|
division: resolve("division", r.division),
|
|
status: resolve("status", r.status),
|
|
contact_person: mainContact?.contact_name || "",
|
|
contact_phone: mainContact?.contact_phone || "",
|
|
email: mainContact?.contact_email || "",
|
|
};
|
|
});
|
|
// 공급업체코드 숫자 기준 내림차순 정렬
|
|
data.sort((a: any, b: any) => {
|
|
const aNum = parseInt((a.supplier_code || "").replace(/\D/g, ""), 10) || 0;
|
|
const bNum = parseInt((b.supplier_code || "").replace(/\D/g, ""), 10) || 0;
|
|
return bNum - aNum;
|
|
});
|
|
setSuppliers(data);
|
|
setSupplierCount(res.data?.data?.total || raw.length);
|
|
} catch (err) {
|
|
console.error("거래처 조회 실패:", err);
|
|
toast.error("공급업체 목록을 불러오는데 실패했습니다.");
|
|
} finally {
|
|
setSupplierLoading(false);
|
|
}
|
|
}, [searchFilters, categoryOptions, employeeOptions, mainContactMap]);
|
|
|
|
useEffect(() => { fetchSuppliers(); }, [fetchSuppliers]);
|
|
|
|
// 메인 담당자 조회 (최초 1번 + 저장 후 갱신)
|
|
const fetchMainContacts = useCallback(async () => {
|
|
try {
|
|
const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
|
page: 1, size: 500, autoFilter: true,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] },
|
|
});
|
|
const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || [];
|
|
const map: Record<string, any> = {};
|
|
for (const c of allContacts) {
|
|
if ((c.is_main === "Y" || c.is_main === true) && c.supplier_id) {
|
|
map[c.supplier_id] = c;
|
|
}
|
|
}
|
|
setMainContactMap(map);
|
|
} catch { /* skip */ }
|
|
}, []);
|
|
|
|
useEffect(() => { fetchMainContacts(); }, [fetchMainContacts]);
|
|
|
|
const selectedSupplier = suppliers.find((c) => c.id === selectedSupplierId);
|
|
|
|
// 선택된 공급업체의 품목 단가 조회
|
|
useEffect(() => {
|
|
if (!selectedSupplier?.supplier_code) { setPriceItems([]); setPriceCheckedIds([]); return; }
|
|
setPriceCheckedIds([]);
|
|
const fetchItems = async () => {
|
|
setPriceLoading(true);
|
|
try {
|
|
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
|
]},
|
|
autoFilter: true,
|
|
});
|
|
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
|
|
|
|
const itemIds = [...new Set(mappings.map((r: any) => r.item_id).filter(Boolean))];
|
|
let itemMap: Record<string, any> = {};
|
|
if (itemIds.length > 0) {
|
|
try {
|
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: itemIds.length + 10,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemIds }] },
|
|
autoFilter: true,
|
|
});
|
|
for (const item of (itemRes.data?.data?.data || itemRes.data?.data?.rows || [])) {
|
|
itemMap[item.item_number] = item;
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
let allPrices: any[] = [];
|
|
if (mappings.length > 0) {
|
|
try {
|
|
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
|
]},
|
|
autoFilter: true,
|
|
});
|
|
allPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
const priceResolve = (col: string, code: string) => {
|
|
if (!code) return "";
|
|
return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
|
};
|
|
const today = new Date().toISOString().split("T")[0];
|
|
|
|
// 품목 기준 그룹핑 — master: 첫 매핑 + 현재 단가, details: 전체 단가 리스트
|
|
const grouped: Record<string, { master: any; details: any[] }> = {};
|
|
const flatItems: any[] = [];
|
|
const seenItemIds = new Set<string>();
|
|
for (const m of mappings) {
|
|
const itemKey = m.item_id || "";
|
|
if (seenItemIds.has(itemKey)) continue; // 품목당 첫 매핑만 마스터
|
|
seenItemIds.add(itemKey);
|
|
|
|
const itemInfo = itemMap[itemKey] || {};
|
|
const itemPriceList = allPrices
|
|
.filter((p: any) => p.item_id === itemKey)
|
|
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
|
|
const todayPrice = itemPriceList.find((p: any) =>
|
|
(!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today)
|
|
) || itemPriceList[0] || {};
|
|
|
|
const masterRow = {
|
|
...m,
|
|
item_number: itemKey,
|
|
item_name: itemInfo.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 = itemPriceList.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[itemKey] = { master: masterRow, details: priceDetails };
|
|
flatItems.push(masterRow);
|
|
}
|
|
setPriceGroups(grouped);
|
|
setPriceItems(flatItems);
|
|
} catch (err) {
|
|
console.error("품목 조회 실패:", err);
|
|
} finally {
|
|
setPriceLoading(false);
|
|
}
|
|
};
|
|
fetchItems();
|
|
}, [selectedSupplier?.supplier_code]);
|
|
|
|
// 납품처 조회
|
|
useEffect(() => {
|
|
if (!selectedSupplier?.supplier_code) { setDeliveryItems([]); return; }
|
|
const fetchDelivery = async () => {
|
|
setDeliveryLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_code", operator: "equals", value: selectedSupplier.supplier_code },
|
|
]},
|
|
autoFilter: true,
|
|
});
|
|
setDeliveryItems(res.data?.data?.data || res.data?.data?.rows || []);
|
|
} catch { setDeliveryItems([]); } finally { setDeliveryLoading(false); }
|
|
};
|
|
fetchDelivery();
|
|
}, [selectedSupplier?.supplier_code]);
|
|
|
|
const getCategoryLabel = (col: string, code: string) => {
|
|
if (!code) return "";
|
|
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
|
};
|
|
|
|
// 모달 내 담당자 목록 조회
|
|
const fetchModalContacts = useCallback(async (supplierId: string) => {
|
|
setModalContactLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
|
page: 1, size: 200,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierId }] },
|
|
autoFilter: true,
|
|
});
|
|
setModalContacts(res.data?.data?.data || res.data?.data?.rows || []);
|
|
} catch { setModalContacts([]); } finally { setModalContactLoading(false); }
|
|
}, []);
|
|
|
|
// 모달 내 납품처 목록 조회
|
|
const fetchModalDeliveries = useCallback(async (supplierCode: string) => {
|
|
setModalDeliveryLoading(true);
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
|
page: 1, size: 200,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "supplier_code", operator: "equals", value: supplierCode }] },
|
|
autoFilter: true,
|
|
});
|
|
setModalDeliveries(res.data?.data?.data || res.data?.data?.rows || []);
|
|
} catch { setModalDeliveries([]); } finally { setModalDeliveryLoading(false); }
|
|
}, []);
|
|
|
|
// 담당자 저장 (등록/수정)
|
|
const handleModalContactSave = async () => {
|
|
if (!modalContactForm.contact_name) { toast.error("담당자명은 필수입니다."); return; }
|
|
if (modalContactEditId) {
|
|
// 수정 — 로컬 리스트에서 교체 + 메인 설정 시 다른 메인 해제
|
|
const isSettingMain = modalContactForm.is_main === "Y" || modalContactForm.is_main === true;
|
|
setModalContacts((prev) => prev.map((c) =>
|
|
(c._localId || c.id) === modalContactEditId
|
|
? { ...c, ...modalContactForm }
|
|
: isSettingMain ? { ...c, is_main: "N" } : c
|
|
));
|
|
} else {
|
|
// 추가 — 로컬 리스트에 카드 추가
|
|
setModalContacts((prev) => [...prev, {
|
|
...modalContactForm,
|
|
_localId: `local_${Date.now()}_${Math.random()}`,
|
|
_isNew: true,
|
|
}]);
|
|
}
|
|
setModalContactFormOpen(false);
|
|
setModalContactForm({});
|
|
setModalContactEditId(null);
|
|
};
|
|
|
|
// 담당자 삭제
|
|
const handleModalContactDelete = (contactId: string) => {
|
|
setModalContacts((prev) => prev.filter((c) => (c._localId || c.id) !== contactId));
|
|
};
|
|
|
|
// 납품처 자동채번 유틸
|
|
const generateDeliveryCode = async () => {
|
|
try {
|
|
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DELIVERY_TABLE}/destination_code`);
|
|
const ruleData = ruleRes.data;
|
|
if (ruleData?.success && ruleData?.data?.ruleId) {
|
|
const ruleId = ruleData.data.ruleId;
|
|
const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
|
page: 1, size: 500, autoFilter: true,
|
|
sort: { columnName: "destination_code", order: "desc" },
|
|
});
|
|
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
|
let maxSeq = 0;
|
|
for (const row of allRows) {
|
|
const match = (row.destination_code || "").match(/(\d+)$/);
|
|
if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; }
|
|
}
|
|
// 로컬에 추가된 것도 포함
|
|
for (const d of modalDeliveries) {
|
|
const match = (d.destination_code || "").match(/(\d+)$/);
|
|
if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; }
|
|
}
|
|
const previewRes = await previewNumberingCode(ruleId);
|
|
if (previewRes.success && previewRes.data?.generatedCode) {
|
|
const previewCode = previewRes.data.generatedCode;
|
|
const prefix = previewCode.replace(/\d+$/, "");
|
|
const seqLen = (previewCode.match(/(\d+)$/) || ["", "001"])[1].length;
|
|
return prefix + String(maxSeq + 1).padStart(seqLen, "0");
|
|
}
|
|
}
|
|
} catch { /* skip */ }
|
|
return "";
|
|
};
|
|
|
|
// 납품처 저장 (모달 내)
|
|
const handleModalDeliverySave = async () => {
|
|
if (!modalDeliveryForm.destination_name) { toast.error("납품처명은 필수입니다."); return; }
|
|
if (modalDeliveryEditId) {
|
|
const isSettingMain = modalDeliveryForm.is_default === "Y" || modalDeliveryForm.is_default === true;
|
|
setModalDeliveries((prev) => prev.map((d) =>
|
|
(d._localId || d.id) === modalDeliveryEditId
|
|
? { ...d, ...modalDeliveryForm }
|
|
: isSettingMain ? { ...d, is_default: "N" } : d
|
|
));
|
|
} else {
|
|
setModalDeliveries((prev) => [...prev, {
|
|
...modalDeliveryForm,
|
|
_localId: `local_${Date.now()}_${Math.random()}`,
|
|
_isNew: true,
|
|
}]);
|
|
}
|
|
setModalDeliveryFormOpen(false);
|
|
setModalDeliveryForm({});
|
|
setModalDeliveryEditId(null);
|
|
setModalDeliveryFormErrors({});
|
|
};
|
|
|
|
const handleModalDeliveryDelete = (deliveryId: string) => {
|
|
setModalDeliveries((prev) => prev.filter((d) => (d._localId || d.id) !== deliveryId));
|
|
};
|
|
|
|
// 공급업체 등록 모달 열기
|
|
const openSupplierRegister = async () => {
|
|
setSupplierForm({});
|
|
setFormErrors({});
|
|
setSupplierEditMode(false);
|
|
setSupplierModalTab("basic");
|
|
setModalContacts([]);
|
|
setModalDeliveries([]);
|
|
setModalContactFormOpen(false);
|
|
setModalDeliveryFormOpen(false);
|
|
setTaxTypeRows([]);
|
|
setSupplierModalOpen(true);
|
|
// 공급업체 코드 자동 채번 — 기존 데이터 max값 기반
|
|
try {
|
|
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${SUPPLIER_TABLE}/supplier_code`);
|
|
const ruleData = ruleRes.data;
|
|
if (ruleData?.success && ruleData?.data?.ruleId) {
|
|
const ruleId = ruleData.data.ruleId;
|
|
// 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회
|
|
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
|
page: 1, size: 500, autoFilter: true,
|
|
sort: { columnName: "supplier_code", order: "desc" },
|
|
});
|
|
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
|
let maxSeq = 0;
|
|
for (const row of allRows) {
|
|
const code = row.supplier_code || "";
|
|
const match = code.match(/(\d+)$/);
|
|
if (match) {
|
|
const seq = parseInt(match[1], 10);
|
|
if (seq > maxSeq) maxSeq = seq;
|
|
}
|
|
}
|
|
// preview로 접두어 패턴 가져오기
|
|
const previewRes = await previewNumberingCode(ruleId);
|
|
if (previewRes.success && previewRes.data?.generatedCode) {
|
|
const previewCode = previewRes.data.generatedCode;
|
|
const prefix = previewCode.replace(/\d+$/, "");
|
|
const seqLength = (previewCode.match(/(\d+)$/) || ["", "001"])[1].length;
|
|
const nextSeq = maxSeq + 1;
|
|
const nextCode = prefix + String(nextSeq).padStart(seqLength, "0");
|
|
setSupplierForm((prev) => ({ ...prev, supplier_code: nextCode, _numberingRuleId: ruleId }));
|
|
}
|
|
}
|
|
} catch { /* skip */ }
|
|
};
|
|
|
|
const openSupplierEdit = () => {
|
|
if (!selectedSupplier) return;
|
|
const rawData = rawSuppliers.find((c) => c.id === selectedSupplierId);
|
|
setSupplierForm({ ...(rawData || selectedSupplier) });
|
|
setFormErrors({});
|
|
setSupplierEditMode(true);
|
|
setSupplierModalTab("basic");
|
|
setModalContactFormOpen(false);
|
|
setModalDeliveryFormOpen(false);
|
|
setModalContactForm({});
|
|
setModalDeliveryForm({});
|
|
setModalContactEditId(null);
|
|
setModalDeliveryEditId(null);
|
|
// 수정 모드에서는 바로 조회
|
|
const code = (rawData || selectedSupplier).supplier_code;
|
|
const id = (rawData || selectedSupplier).id;
|
|
if (id) {
|
|
fetchModalContacts(id);
|
|
// 세금유형 로드
|
|
apiClient.post(`/table-management/tables/supplier_tax_type/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: id }] },
|
|
autoFilter: true,
|
|
}).then((res: any) => {
|
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setTaxTypeRows(rows.map((r: any) => ({ _id: r.id, tax_type_name: r.tax_type_name || "", rate: String(r.rate || "") })));
|
|
}).catch(() => setTaxTypeRows([]));
|
|
}
|
|
if (code) fetchModalDeliveries(code);
|
|
setSupplierModalOpen(true);
|
|
};
|
|
|
|
// 폼 필드 변경 시 자동 포맷팅 + 실시간 검증
|
|
const handleFormChange = (field: string, value: string) => {
|
|
const formatted = formatField(field, value);
|
|
setSupplierForm((prev) => ({ ...prev, [field]: formatted }));
|
|
const error = validateField(field, formatted);
|
|
setFormErrors((prev) => {
|
|
const next = { ...prev };
|
|
if (error) next[field] = error; else delete next[field];
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// 세금유형/담당자/납품처를 한번에 저장하는 헬퍼
|
|
const saveSubTables = async (supplierId: string, supplierCode: string) => {
|
|
// 세금유형 — 기존 삭제 후 재생성
|
|
try {
|
|
const existTax = await apiClient.post(`/table-management/tables/supplier_tax_type/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierId }] },
|
|
autoFilter: true,
|
|
});
|
|
const existRows = existTax.data?.data?.data || existTax.data?.data?.rows || [];
|
|
if (existRows.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/supplier_tax_type/delete`, {
|
|
data: existRows.map((r: any) => ({ id: r.id })),
|
|
});
|
|
}
|
|
for (const t of taxTypeRows.filter((r) => r.tax_type_name)) {
|
|
await apiClient.post(`/table-management/tables/supplier_tax_type/add`, {
|
|
id: crypto.randomUUID(), supplier_id: supplierId,
|
|
tax_type_name: t.tax_type_name, tax_type_id: t.tax_type_name,
|
|
rate: t.rate ? Number(t.rate) : 0,
|
|
});
|
|
}
|
|
} catch { /* skip */ }
|
|
|
|
// 담당자 — 기존 삭제 후 전체 재생성
|
|
try {
|
|
const existContacts = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierId }] },
|
|
autoFilter: true,
|
|
});
|
|
const existCRows = existContacts.data?.data?.data || existContacts.data?.data?.rows || [];
|
|
if (existCRows.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/${CONTACT_TABLE}/delete`, {
|
|
data: existCRows.map((r: any) => ({ id: r.id })),
|
|
});
|
|
}
|
|
} catch { /* skip */ }
|
|
for (const c of modalContacts) {
|
|
try {
|
|
await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/add`, {
|
|
id: crypto.randomUUID(), supplier_id: supplierId,
|
|
contact_name: c.contact_name || "", contact_phone: c.contact_phone || "",
|
|
contact_email: c.contact_email || "", department: c.department || "",
|
|
is_main: c.is_main || "N", memo: c.memo || "",
|
|
});
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
// 납품처 — 기존 삭제 후 전체 재생성
|
|
try {
|
|
const existDeliveries = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "supplier_code", operator: "equals", value: supplierCode }] },
|
|
autoFilter: true,
|
|
});
|
|
const existDRows = existDeliveries.data?.data?.data || existDeliveries.data?.data?.rows || [];
|
|
if (existDRows.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/${DELIVERY_TABLE}/delete`, {
|
|
data: existDRows.map((r: any) => ({ id: r.id })),
|
|
});
|
|
}
|
|
} catch { /* skip */ }
|
|
for (const d of modalDeliveries) {
|
|
try {
|
|
await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/add`, {
|
|
id: crypto.randomUUID(), supplier_code: supplierCode,
|
|
destination_code: d.destination_code || "", destination_name: d.destination_name || "",
|
|
address: d.address || "", manager_name: d.manager_name || "",
|
|
phone: d.phone || "", memo: d.memo || "", is_default: d.is_default || "N",
|
|
});
|
|
} catch { /* skip */ }
|
|
}
|
|
};
|
|
|
|
const handleSupplierSave = async () => {
|
|
if (!supplierForm.supplier_name) { toast.error("공급업체명은 필수입니다."); return; }
|
|
if (!supplierForm.status) { toast.error("상태는 필수입니다."); return; }
|
|
const errors = validateForm(supplierForm, ["contact_phone", "email", "business_number"]);
|
|
setFormErrors(errors);
|
|
if (Object.keys(errors).length > 0) {
|
|
toast.error("입력 형식을 확인해주세요.");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const { id, created_date, updated_date, writer, company_code, _numberingRuleId, ...fields } = supplierForm;
|
|
const cleanFields: Record<string, any> = {};
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
cleanFields[key] = value === "" ? null : value;
|
|
}
|
|
|
|
if (supplierEditMode && id) {
|
|
// 수정
|
|
await apiClient.put(`/table-management/tables/${SUPPLIER_TABLE}/edit`, {
|
|
originalData: { id }, updatedData: cleanFields,
|
|
});
|
|
await saveSubTables(id, cleanFields.supplier_code || supplierForm.supplier_code);
|
|
toast.success("저장되었습니다.");
|
|
} else {
|
|
// 신규 등록
|
|
await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/add`, cleanFields);
|
|
// id 획득
|
|
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
|
page: 1, size: 1,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "supplier_code", operator: "equals", value: cleanFields.supplier_code }] },
|
|
autoFilter: true,
|
|
});
|
|
const newRow = (res.data?.data?.data || res.data?.data?.rows || [])[0];
|
|
if (newRow?.id) {
|
|
await saveSubTables(newRow.id, cleanFields.supplier_code);
|
|
}
|
|
toast.success("공급업체가 등록되었습니다.");
|
|
}
|
|
|
|
fetchSuppliers();
|
|
fetchMainContacts();
|
|
if (!supplierEditMode && continuousInput) {
|
|
// 연속입력 — 폼 초기화하고 모달 유지
|
|
setSupplierForm({});
|
|
setModalContacts([]);
|
|
setModalDeliveries([]);
|
|
setTaxTypeRows([]);
|
|
setSupplierModalTab("basic");
|
|
// 새 코드 채번
|
|
try {
|
|
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${SUPPLIER_TABLE}/supplier_code`);
|
|
const ruleData = ruleRes.data;
|
|
if (ruleData?.success && ruleData?.data?.ruleId) {
|
|
const ruleId = ruleData.data.ruleId;
|
|
const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } });
|
|
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
|
|
let maxSeq = 0;
|
|
for (const row of allRows) { const match = (row.supplier_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } }
|
|
const previewRes = await previewNumberingCode(ruleId);
|
|
if (previewRes.success && previewRes.data?.generatedCode) {
|
|
const prefix = previewRes.data.generatedCode.replace(/\d+$/, "");
|
|
const seqLen = (previewRes.data.generatedCode.match(/(\d+)$/) || ["", "001"])[1].length;
|
|
setSupplierForm({ supplier_code: prefix + String(maxSeq + 1).padStart(seqLen, "0") });
|
|
}
|
|
}
|
|
} catch { /* skip */ }
|
|
toast.success("등록 완료. 다음 공급업체를 입력하세요.");
|
|
} else {
|
|
setSupplierModalOpen(false);
|
|
// 우측 패널 갱신
|
|
if (selectedSupplierId) {
|
|
const cid = selectedSupplierId;
|
|
setSelectedSupplierId(null);
|
|
setTimeout(() => setSelectedSupplierId(cid), 50);
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// 공급업체 삭제
|
|
const handleSupplierDelete = async () => {
|
|
if (!selectedSupplierId) return;
|
|
const ok = await confirm("공급업체를 삭제하시겠습니까?", {
|
|
variant: "destructive", confirmText: "삭제",
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await apiClient.delete(`/table-management/tables/${SUPPLIER_TABLE}/delete`, {
|
|
data: [{ id: selectedSupplierId }],
|
|
});
|
|
toast.success("삭제되었습니다.");
|
|
setSelectedSupplierId(null);
|
|
fetchSuppliers();
|
|
} catch { toast.error("삭제에 실패했습니다."); }
|
|
};
|
|
|
|
// 품목 검색
|
|
const searchItems = useCallback(async () => {
|
|
setItemSearchLoading(true);
|
|
try {
|
|
const filters: any[] = [];
|
|
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setItemTotalCount(allItems.length);
|
|
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
|
|
const purchaseCode = categoryOptions["item_division"]?.find((o) => o.label === "구매관리")?.code;
|
|
setItemSearchResults(allItems.filter((item: any) => {
|
|
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
|
|
if (!purchaseCode) return true;
|
|
const div = item.division || "";
|
|
return div.includes(purchaseCode);
|
|
}));
|
|
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
|
}, [itemSearchKeyword, priceItems]);
|
|
|
|
// 실��간 검색 (2글자 이상)
|
|
useEffect(() => {
|
|
if (!itemSelectOpen) return;
|
|
if (itemSearchKeyword.length > 0 && itemSearchKeyword.length < 2) return;
|
|
searchItems();
|
|
}, [itemSearchKeyword, itemSelectOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 품목 선택 완료 → 상세 입력 모달로 전환
|
|
const goToItemDetail = () => {
|
|
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
|
|
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
|
setSelectedItemsForDetail(selected);
|
|
const mappings: typeof itemMappings = {};
|
|
const prices: typeof itemPrices = {};
|
|
for (const item of selected) {
|
|
const key = item.item_number || item.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: item.standard_price || item.selling_price || "",
|
|
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
|
|
calculated_price: item.standard_price || item.selling_price || "",
|
|
}];
|
|
}
|
|
setItemMappings(mappings);
|
|
setItemPrices(prices);
|
|
setItemSelectOpen(false);
|
|
setItemDetailOpen(true);
|
|
};
|
|
|
|
// 거래처 품번/품명 행 추가
|
|
const addMappingRow = (itemKey: string) => {
|
|
setItemMappings((prev) => ({
|
|
...prev,
|
|
[itemKey]: [...(prev[itemKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, supplier_item_code: "", supplier_item_name: "" }],
|
|
}));
|
|
};
|
|
|
|
const removeMappingRow = (itemKey: string, rowId: string) => {
|
|
setItemMappings((prev) => ({
|
|
...prev,
|
|
[itemKey]: (prev[itemKey] || []).filter((r) => r._id !== rowId),
|
|
}));
|
|
};
|
|
|
|
const handleMappingDragEnd = (itemKey: string, event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
setItemMappings((prev) => {
|
|
const arr = [...(prev[itemKey] || [])];
|
|
const oldIdx = arr.findIndex((r) => r._id === active.id);
|
|
const newIdx = arr.findIndex((r) => r._id === over.id);
|
|
return { ...prev, [itemKey]: arrayMove(arr, oldIdx, newIdx) };
|
|
});
|
|
};
|
|
|
|
const updateMappingRow = (itemKey: string, rowId: string, field: string, value: string) => {
|
|
setItemMappings((prev) => ({
|
|
...prev,
|
|
[itemKey]: (prev[itemKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
|
|
}));
|
|
};
|
|
|
|
// 단가 행 추가
|
|
const addPriceRow = (itemKey: string) => {
|
|
setItemPrices((prev) => ({
|
|
...prev,
|
|
[itemKey]: [...(prev[itemKey] || []), {
|
|
_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 = (itemKey: string, rowId: string) => {
|
|
setItemPrices((prev) => ({
|
|
...prev,
|
|
[itemKey]: (prev[itemKey] || []).filter((r) => r._id !== rowId),
|
|
}));
|
|
};
|
|
|
|
const updatePriceRow = (itemKey: string, rowId: string, field: string, value: string) => {
|
|
setItemPrices((prev) => ({
|
|
...prev,
|
|
[itemKey]: (prev[itemKey] || []).map((r) => {
|
|
if (r._id !== rowId) return r;
|
|
const updated = { ...r, [field]: value };
|
|
if (["base_price", "discount_type", "discount_value", "rounding_unit_value", "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; // "10원" → 10, "100원" → 100
|
|
if (roundLabel === "반올림") {
|
|
calc = Math.round(calc / unit) * unit;
|
|
} else if (roundLabel === "절삭") {
|
|
calc = Math.floor(calc / unit) * unit;
|
|
} else if (roundLabel === "올림") {
|
|
calc = Math.ceil(calc / unit) * unit;
|
|
}
|
|
updated.calculated_price = String(Math.floor(calc));
|
|
}
|
|
return updated;
|
|
}),
|
|
}));
|
|
};
|
|
|
|
// 품목 편집 열기
|
|
const openEditItem = async (row: any) => {
|
|
const itemKey = row.item_number || row.item_id;
|
|
let itemInfo: any = { item_number: itemKey, item_name: row.item_name || "", size: "", unit: "" };
|
|
try {
|
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: 1,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "equals", value: itemKey }] },
|
|
autoFilter: true,
|
|
});
|
|
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
|
|
if (found) itemInfo = 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: selectedSupplier!.supplier_code },
|
|
{ columnName: "item_id", operator: "equals", value: itemKey },
|
|
]}, 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/${PRICE_TABLE}/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
|
{ columnName: "item_id", operator: "equals", value: itemKey },
|
|
]}, 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: "",
|
|
});
|
|
}
|
|
|
|
setSelectedItemsForDetail([itemInfo]);
|
|
setItemMappings({ [itemKey]: mappingRows });
|
|
setItemPrices({ [itemKey]: priceRows });
|
|
setEditItemData(row);
|
|
setItemDetailOpen(true);
|
|
};
|
|
|
|
const handleItemDetailSave = async () => {
|
|
if (!selectedSupplier) return;
|
|
if (savingRef.current) return;
|
|
savingRef.current = true;
|
|
const isEditingExisting = !!editItemData;
|
|
setSaving(true);
|
|
try {
|
|
for (const item of selectedItemsForDetail) {
|
|
const itemKey = item.item_number || item.id;
|
|
const mappingRows = itemMappings[itemKey] || [];
|
|
|
|
if (isEditingExisting && editItemData?.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: selectedSupplier.supplier_code },
|
|
{ columnName: "item_id", operator: "equals", value: itemKey },
|
|
]}, autoFilter: true,
|
|
sort: { columnName: "created_date", order: "asc" },
|
|
});
|
|
existingMaps = existingMappings.data?.data?.data || existingMappings.data?.data?.rows || [];
|
|
} catch { /* skip */ }
|
|
|
|
// 매핑 upsert: 기존 것은 update, 새 것은 insert, 남은 것은 delete
|
|
const usedExistingIds = new Set<string>();
|
|
let firstMappingId: string | null = editItemData.id;
|
|
for (let mi = 0; mi < mappingRows.length; mi++) {
|
|
const existMap = existingMaps[mi];
|
|
if (existMap) {
|
|
// update
|
|
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 {
|
|
// insert
|
|
const mRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
|
id: crypto.randomUUID(),
|
|
supplier_id: selectedSupplier.supplier_code, item_id: itemKey,
|
|
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/${PRICE_TABLE}/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
|
{ columnName: "item_id", operator: "equals", value: itemKey },
|
|
]}, autoFilter: true,
|
|
});
|
|
existingPriceRows = (existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [])
|
|
.sort((a: any, b: any) => (a.start_date || "").localeCompare(b.start_date || ""));
|
|
} catch { /* skip */ }
|
|
|
|
// 단가 upsert
|
|
const priceRows = (itemPrices[itemKey] || []).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 || editItemData.id,
|
|
supplier_id: selectedSupplier.supplier_code, item_id: itemKey,
|
|
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/${PRICE_TABLE}/edit`, {
|
|
originalData: { id: existPrice.id },
|
|
updatedData: priceData,
|
|
});
|
|
usedPriceIds.add(existPrice.id);
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/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/${PRICE_TABLE}/delete`, {
|
|
data: toDeletePrices.map((p: any) => ({ id: p.id })),
|
|
});
|
|
}
|
|
} else {
|
|
if (!mappingRows.length || !mappingRows[0]?.supplier_item_code) {
|
|
const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
|
page: 1, size: 1,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code },
|
|
{ columnName: "item_id", operator: "equals", value: itemKey },
|
|
]}, autoFilter: true,
|
|
});
|
|
if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) {
|
|
toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let mappingId: string | null = null;
|
|
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
|
id: crypto.randomUUID(),
|
|
supplier_id: selectedSupplier.supplier_code, item_id: itemKey,
|
|
supplier_item_code: mappingRows[0]?.supplier_item_code || "",
|
|
supplier_item_name: mappingRows[0]?.supplier_item_name || "",
|
|
});
|
|
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: selectedSupplier.supplier_code, item_id: itemKey,
|
|
supplier_item_code: mappingRows[mi].supplier_item_code || "",
|
|
supplier_item_name: mappingRows[mi].supplier_item_name || "",
|
|
});
|
|
}
|
|
|
|
const priceRows = (itemPrices[itemKey] || []).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/${PRICE_TABLE}/add`, {
|
|
id: crypto.randomUUID(),
|
|
mapping_id: mappingId || "", supplier_id: selectedSupplier.supplier_code, item_id: itemKey,
|
|
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 ? "수정되었습니다." : `${selectedItemsForDetail.length}개 품목이 추가되었습니다.`);
|
|
setItemDetailOpen(false);
|
|
setEditItemData(null);
|
|
setItemCheckedIds(new Set());
|
|
const cid = selectedSupplierId;
|
|
setSelectedSupplierId(null);
|
|
setTimeout(() => setSelectedSupplierId(cid), 50);
|
|
} catch (err: any) {
|
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
|
} finally {
|
|
setSaving(false);
|
|
savingRef.current = false;
|
|
}
|
|
};
|
|
|
|
// 품목 매핑 해제 (소프트 삭제 — supplier_id를 null 처리)
|
|
const handlePriceItemDelete = async () => {
|
|
if (priceCheckedIds.length === 0) return;
|
|
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목의 연결을 해제하시겠습니까?`, {
|
|
description: "해당 품목의 공급업체 연결이 해제됩니다. (데이터는 유지)",
|
|
variant: "destructive", confirmText: "해제",
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
const itemIds = priceCheckedIds.map((mid) => {
|
|
const group = Object.values(priceGroups).find((g) => g.master.id === mid);
|
|
return group?.master.item_id || group?.master.item_number || "";
|
|
}).filter(Boolean);
|
|
|
|
for (const itemId of itemIds) {
|
|
// 해당 품목의 모든 매핑 조회 → supplier_id null 처리
|
|
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
|
{ columnName: "item_id", operator: "equals", value: itemId },
|
|
]}, 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: { supplier_id: null },
|
|
});
|
|
}
|
|
|
|
// 해당 품목의 모든 단가 조회 → supplier_id null 처리
|
|
try {
|
|
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code },
|
|
{ columnName: "item_id", operator: "equals", value: itemId },
|
|
]}, autoFilter: true,
|
|
});
|
|
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
|
|
for (const p of prices) {
|
|
await apiClient.put(`/table-management/tables/${PRICE_TABLE}/edit`, {
|
|
originalData: { id: p.id },
|
|
updatedData: { supplier_id: null },
|
|
});
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
toast.success(`${priceCheckedIds.length}개 품목의 연결이 해제되었습니다.`);
|
|
setPriceCheckedIds([]);
|
|
const cid = selectedSupplierId;
|
|
setSelectedSupplierId(null);
|
|
setTimeout(() => setSelectedSupplierId(cid), 50);
|
|
} catch {
|
|
toast.error("연결 해제에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// EDataTable 컬럼 정의 (공급업체 목록) — ts.visibleColumns 순서를 따름
|
|
const supplierColumns: EDataTableColumn[] = useMemo(() => {
|
|
const colProps: Record<string, Partial<EDataTableColumn>> = {
|
|
supplier_code: { width: "w-[120px]" },
|
|
supplier_name: { minWidth: "min-w-[140px]" },
|
|
division: {
|
|
width: "w-[80px]",
|
|
render: (val: any) =>
|
|
val ? (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
|
|
{val}
|
|
</Badge>
|
|
) : null,
|
|
},
|
|
contact_person: { width: "w-[80px]" },
|
|
contact_phone: { width: "w-[120px]" },
|
|
email: { width: "w-[160px]" },
|
|
business_number: { width: "w-[120px]" },
|
|
address: { minWidth: "min-w-[150px]" },
|
|
status: {
|
|
width: "w-[70px]",
|
|
render: (val: any) =>
|
|
val ? (
|
|
<Badge
|
|
variant={val === "활성" || val === "거래중" || val === "정상" ? "default" as const : "outline" as const}
|
|
className="text-[10px] px-1.5 py-0 h-5"
|
|
>
|
|
{val}
|
|
</Badge>
|
|
) : null,
|
|
},
|
|
};
|
|
return ts.visibleColumns.map((col) => ({
|
|
key: col.key,
|
|
label: col.label,
|
|
...colProps[col.key],
|
|
}));
|
|
}, [ts.visibleColumns]);
|
|
|
|
// 엑셀 다운로드
|
|
const handleExcelDownload = async () => {
|
|
if (suppliers.length === 0) return;
|
|
toast.loading("엑셀 데이터 준비 중...", { id: "excel-dl" });
|
|
try {
|
|
const allMappings: any[] = [];
|
|
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
|
page: 1, size: 5000, autoFilter: true,
|
|
});
|
|
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
|
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
|
|
let itemMap: Record<string, any> = {};
|
|
if (itemIds.length > 0) {
|
|
try {
|
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: itemIds.length + 10,
|
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemIds }] },
|
|
autoFilter: true,
|
|
});
|
|
for (const item of (itemRes.data?.data?.data || itemRes.data?.data?.rows || [])) {
|
|
itemMap[item.item_number] = item;
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
for (const m of mappings) {
|
|
const itemInfo = itemMap[m.item_id] || {};
|
|
allMappings.push({ ...m, item_name: itemInfo.item_name || "", item_spec: itemInfo.size || "" });
|
|
}
|
|
|
|
const rows: Record<string, any>[] = [];
|
|
for (const c of suppliers) {
|
|
const suppMappings = allMappings.filter((m) => m.supplier_id === c.supplier_code);
|
|
if (suppMappings.length === 0) {
|
|
rows.push({
|
|
공급업체코드: c.supplier_code, 공급업체명: c.supplier_name,
|
|
공급업체유형: getCategoryLabel("division", c.division),
|
|
담당자: c.contact_person, 전화번호: c.contact_phone,
|
|
사업자번호: c.business_number, 이메일: c.email,
|
|
상태: getCategoryLabel("status", c.status),
|
|
품목코드: "", 품명: "", 규격: "",
|
|
공급업체품번: "", 공급업체품명: "",
|
|
기준가: "", 할인유형: "", 할인값: "", 단가: "", 통화: "",
|
|
});
|
|
} else {
|
|
for (const m of suppMappings) {
|
|
rows.push({
|
|
공급업체코드: c.supplier_code, 공급업체명: c.supplier_name,
|
|
공급업체유형: getCategoryLabel("division", c.division),
|
|
담당자: c.contact_person, 전화번호: c.contact_phone,
|
|
사업자번호: c.business_number, 이메일: c.email,
|
|
상태: getCategoryLabel("status", c.status),
|
|
품목코드: m.item_id || "", 품명: m.item_name || "", 규격: m.item_spec || "",
|
|
공급업체품번: m.supplier_item_code || "", 공급업체품명: m.supplier_item_name || "",
|
|
기준가: m.base_price || "", 할인유형: m.discount_type || "", 할인값: m.discount_value || "",
|
|
단가: m.calculated_price || "", 통화: m.currency_code || "",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
await exportToExcel(rows, "공급업체관리.xlsx", "거래처+품목");
|
|
toast.dismiss("excel-dl");
|
|
toast.success(`${rows.length}행 다운로드 완료`);
|
|
} catch (err) {
|
|
toast.dismiss("excel-dl");
|
|
toast.error("다운로드에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col gap-3 p-4">
|
|
{/* 검색 필터 (DynamicSearchFilter) */}
|
|
<DynamicSearchFilter
|
|
tableName={SUPPLIER_TABLE}
|
|
filterId="c16-supplier"
|
|
onFilterChange={setSearchFilters}
|
|
dataCount={suppliers.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(SUPPLIER_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">
|
|
{supplierCount}건
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<label className="flex items-center gap-1.5 cursor-pointer mr-1">
|
|
<input type="checkbox" checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} className="rounded" />
|
|
<span className="text-[11px] text-muted-foreground">거래정지 포함</span>
|
|
</label>
|
|
<Button size="sm" onClick={openSupplierRegister}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 등록
|
|
</Button>
|
|
<Button variant="outline" size="sm" disabled={!selectedSupplierId} onClick={openSupplierEdit}>
|
|
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
|
</Button>
|
|
<Button variant="destructive" size="sm" disabled={!selectedSupplierId} onClick={handleSupplierDelete}>
|
|
<Trash2 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={supplierColumns}
|
|
data={ts.groupData(showInactive ? suppliers : suppliers.filter((c) => c.status !== "거래정지"))}
|
|
rowKey={(row) => row.id}
|
|
loading={supplierLoading}
|
|
emptyMessage="등록된 공급업체가 없어요"
|
|
selectedId={selectedSupplierId}
|
|
onSelect={(id) => setSelectedSupplierId(id)}
|
|
onRowDoubleClick={(row) => { setSelectedSupplierId(row.id); openSupplierEdit(); }}
|
|
showRowNumber
|
|
showPagination
|
|
defaultPageSize={20}
|
|
draggableColumns={false}
|
|
columnOrderKey="c16-supplier"
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 우측: 디테일 패널 */}
|
|
<ResizablePanel defaultSize={45} minSize={25}>
|
|
<div className="flex flex-col h-full">
|
|
{!selectedSupplierId ? (
|
|
/* 빈 상태 */
|
|
<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">
|
|
<Users 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>
|
|
) : (
|
|
<>
|
|
{/* 탭 + 버튼 통합 헤더 */}
|
|
<Tabs
|
|
value={rightTab}
|
|
onValueChange={(v) => setRightTab(v as "items" | "delivery")}
|
|
className="flex flex-col flex-1 overflow-hidden gap-0"
|
|
>
|
|
<div className="flex items-center justify-between h-[42px] border-b bg-muted shrink-0 pr-3">
|
|
<TabsList className="bg-transparent h-auto p-0 rounded-none">
|
|
<TabsTrigger
|
|
value="items"
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5 text-xs font-semibold gap-1.5"
|
|
>
|
|
<Package className="w-3.5 h-3.5" /> 공급업체별 품목정보
|
|
{Object.keys(priceGroups).length > 0 && (
|
|
<Badge variant="secondary" className="ml-0.5 text-[10px] px-1.5 py-0">{Object.keys(priceGroups).length}</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="delivery"
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5 text-xs font-semibold gap-1.5"
|
|
>
|
|
<MapPin className="w-3.5 h-3.5" /> 납품처 정보
|
|
{deliveryItems.length > 0 && (
|
|
<Badge variant="secondary" className="ml-0.5 text-[10px] px-1.5 py-0">{deliveryItems.length}</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<div className="flex gap-1.5">
|
|
{rightTab === "items" ? (
|
|
<>
|
|
<Button size="sm" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
|
<Plus className="w-3.5 h-3.5" /> 품목 추가
|
|
</Button>
|
|
<Button variant="destructive" size="sm" disabled={priceCheckedIds.length === 0} onClick={handlePriceItemDelete}>
|
|
<Trash2 className="w-3.5 h-3.5" /> 삭제
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button size="sm" variant="outline" onClick={openSupplierEdit}>
|
|
<Pencil className="w-3.5 h-3.5 mr-1" /> 공급업체 수정에서 관리
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 품목정보 탭 */}
|
|
<TabsContent value="items" className="flex flex-col flex-1 overflow-hidden mt-0">
|
|
<div className="flex-1 min-h-0 overflow-auto pt-px">
|
|
<Table>
|
|
<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={priceItems.length > 0 && priceCheckedIds.length === priceItems.length}
|
|
onChange={(e) => setPriceCheckedIds(e.target.checked ? priceItems.map((p) => p.id) : [])}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
|
<TableHead className="min-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-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체품명</TableHead>
|
|
<TableHead className="w-[80px] 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-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">할인유형</TableHead>
|
|
<TableHead className="w-[60px] 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>
|
|
{priceLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={11} className="text-center py-8">
|
|
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : Object.keys(priceGroups).length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground text-sm">
|
|
등록된 품목이 없어요
|
|
</TableCell>
|
|
</TableRow>
|
|
) : Object.entries(priceGroups).map(([itemKey, group]) => {
|
|
const isExpanded = expandedItems.has(itemKey);
|
|
const m = group.master;
|
|
const isChecked = priceCheckedIds.includes(m.id);
|
|
return (
|
|
<React.Fragment key={itemKey}>
|
|
{/* 마스터 행 */}
|
|
<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(itemKey)) next.delete(itemKey); else next.add(itemKey);
|
|
return next;
|
|
});
|
|
}}
|
|
onDoubleClick={() => openEditItem(m)}
|
|
>
|
|
<TableCell
|
|
className="text-center px-2"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setPriceCheckedIds((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.item_number}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-[13px]">{m.item_name}</TableCell>
|
|
<TableCell className="text-[13px]">{m.supplier_item_code}</TableCell>
|
|
<TableCell className="text-[13px]">{m.supplier_item_name}</TableCell>
|
|
<TableCell className="text-[13px]">{m.base_price_type}</TableCell>
|
|
<TableCell className="text-[13px] text-right">
|
|
{m.base_price ? Number(m.base_price).toLocaleString() : ""}
|
|
</TableCell>
|
|
<TableCell className="text-[13px]">{m.discount_type}</TableCell>
|
|
<TableCell className="text-[13px] text-right">{m.discount_value ? Number(m.discount_value).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={11} className="py-3 px-4 text-xs text-muted-foreground">등록된 단가가 없어요</TableCell>
|
|
</TableRow>
|
|
);
|
|
return (
|
|
<TableRow className="border-l-[3px] border-l-primary/30">
|
|
<TableCell colSpan={11} 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>
|
|
</TabsContent>
|
|
|
|
{/* 납품처 탭 */}
|
|
<TabsContent value="delivery" className="flex flex-col flex-1 overflow-hidden mt-0">
|
|
<div className="flex-1 min-h-0 overflow-auto pt-px">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted h-10">
|
|
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납품처코드</TableHead>
|
|
<TableHead className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납품처명</TableHead>
|
|
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">주소</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>
|
|
<TableHead className="w-[110px] 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-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메인</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{deliveryLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center py-8">
|
|
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : deliveryItems.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm">
|
|
등록된 납품처가 없어요
|
|
</TableCell>
|
|
</TableRow>
|
|
) : deliveryItems.map((d) => (
|
|
<TableRow key={d.id} className="h-[41px]">
|
|
<TableCell className="text-[13px] font-mono text-muted-foreground">{d.destination_code}</TableCell>
|
|
<TableCell className="text-sm">{d.destination_name}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{d.address}</TableCell>
|
|
<TableCell className="text-[13px]">{d.manager_name}</TableCell>
|
|
<TableCell className="text-[13px]">{d.phone}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{d.memo}</TableCell>
|
|
<TableCell className="text-center">
|
|
{d.is_default && (
|
|
<Badge variant="default" className="text-[10px] px-1.5 py-0 h-5">메인</Badge>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
|
|
{/* ── 모달: 공급업체 등록/수정 (3탭) ── */}
|
|
<Dialog open={supplierModalOpen} onOpenChange={(open) => {
|
|
if (!open && isConfirmOpenRef.current) return;
|
|
setSupplierModalOpen(open);
|
|
if (!open) {
|
|
setModalContactFormOpen(false);
|
|
setModalDeliveryFormOpen(false);
|
|
setModalContactForm({});
|
|
setModalDeliveryForm({});
|
|
setModalContactEditId(null);
|
|
setModalDeliveryEditId(null);
|
|
fetchSuppliers();
|
|
if (supplierForm.supplier_code) {
|
|
const cid = selectedSupplierId;
|
|
setSelectedSupplierId(null);
|
|
setTimeout(() => setSelectedSupplierId(cid), 50);
|
|
}
|
|
}
|
|
}}>
|
|
<DialogContent className="max-w-4xl h-[85vh] flex flex-col overflow-hidden p-0">
|
|
<DialogHeader className="px-6 pt-5 pb-0 shrink-0">
|
|
<DialogTitle>{supplierEditMode ? "공급업체 수정" : "공급업체 등록"}</DialogTitle>
|
|
<DialogDescription>
|
|
{supplierEditMode ? "공급업체 정보를 수정합니다." : "새 공급업체를 등록합니다."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs value={supplierModalTab} onValueChange={(v: any) => setSupplierModalTab(v)} className="flex flex-col flex-1 overflow-hidden">
|
|
<div className="shrink-0">
|
|
<TabsList className="bg-transparent w-full h-auto p-0 rounded-none gap-0">
|
|
<TabsTrigger value="basic" className="flex-1 rounded-none border-r border-border py-2.5 text-[13px] font-semibold gap-1.5 data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground data-[state=active]:shadow-none">
|
|
기본정보
|
|
</TabsTrigger>
|
|
<TabsTrigger value="contacts" className="flex-1 rounded-none border-r border-border py-2.5 text-[13px] font-semibold gap-1.5 data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground data-[state=active]:shadow-none">
|
|
담당자 관리
|
|
{modalContacts.length > 0 && (
|
|
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] text-[10px] font-bold bg-primary/10 text-primary rounded-full px-1">{modalContacts.length}</span>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="delivery" className="flex-1 rounded-none py-2.5 text-[13px] font-semibold gap-1.5 data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground data-[state=active]:shadow-none">
|
|
납품처 관리
|
|
{modalDeliveries.length > 0 && (
|
|
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] text-[10px] font-bold bg-primary/10 text-primary rounded-full px-1">{modalDeliveries.length}</span>
|
|
)}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* 기본정보 탭 */}
|
|
<TabsContent value="basic" className="flex-1 overflow-y-auto mt-0">
|
|
<div className="px-6 py-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">공급업체 코드</Label>
|
|
<Input
|
|
value={supplierForm.supplier_code || ""}
|
|
onChange={(e) => setSupplierForm((p) => ({ ...p, supplier_code: e.target.value }))}
|
|
placeholder={supplierEditMode ? "" : "자동 생성"}
|
|
className={cn("h-9 font-mono", !supplierEditMode && supplierForm.supplier_code && "bg-muted")}
|
|
readOnly={!supplierEditMode && !!supplierForm.supplier_code}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">공급업체명 <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
value={supplierForm.supplier_name || ""}
|
|
onChange={(e) => setSupplierForm((p) => ({ ...p, supplier_name: e.target.value }))}
|
|
placeholder="공급업체명"
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">공급업체 유형</Label>
|
|
<Select
|
|
value={supplierForm.division || "__none__"}
|
|
onValueChange={(v) => setSupplierForm((p) => ({ ...p, division: v === "__none__" ? "" : v }))}
|
|
>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="공급업체 유형" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
|
{(categoryOptions["division"] || []).map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">상태 <span className="text-destructive">*</span></Label>
|
|
<Select
|
|
value={supplierForm.status || "__none__"}
|
|
onValueChange={(v) => setSupplierForm((p) => ({ ...p, status: v === "__none__" ? "" : v }))}
|
|
>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="상태" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
|
{(categoryOptions["status"] || []).map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">공급업체담당자</Label>
|
|
<Input
|
|
value={supplierForm.contact_person || ""}
|
|
onChange={(e) => setSupplierForm((p) => ({ ...p, contact_person: e.target.value }))}
|
|
placeholder="공급업체담당자"
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">전화번호</Label>
|
|
<Input
|
|
value={supplierForm.contact_phone || ""}
|
|
onChange={(e) => handleFormChange("contact_phone", e.target.value)}
|
|
placeholder="010-0000-0000"
|
|
className={cn("h-9", formErrors.contact_phone && "border-destructive")}
|
|
/>
|
|
{formErrors.contact_phone && <p className="text-xs text-destructive">{formErrors.contact_phone}</p>}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">이메일</Label>
|
|
<Input
|
|
value={supplierForm.email || ""}
|
|
onChange={(e) => handleFormChange("email", e.target.value)}
|
|
placeholder="example@email.com"
|
|
className={cn("h-9", formErrors.email && "border-destructive")}
|
|
/>
|
|
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">사업자번호</Label>
|
|
<Input
|
|
value={supplierForm.business_number || ""}
|
|
onChange={(e) => handleFormChange("business_number", e.target.value)}
|
|
placeholder="000-00-00000"
|
|
className={cn("h-9", formErrors.business_number && "border-destructive")}
|
|
/>
|
|
{formErrors.business_number && <p className="text-xs text-destructive">{formErrors.business_number}</p>}
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-sm">주소</Label>
|
|
<Input
|
|
value={supplierForm.address || ""}
|
|
onChange={(e) => setSupplierForm((p) => ({ ...p, address: e.target.value }))}
|
|
placeholder="주소"
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 세금유형 */}
|
|
<div className="mt-4 pt-4 border-t">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<Label className="text-sm font-semibold flex items-center gap-1.5">
|
|
<Coins className="w-3.5 h-3.5 text-primary" /> 세금유형
|
|
</Label>
|
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => {
|
|
setTaxTypeRows((prev) => [...prev, { _id: `tax_${Date.now()}_${Math.random()}`, tax_type_name: "", rate: "" }]);
|
|
}}>
|
|
<Plus className="h-3 w-3 mr-1" /> 추가
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{taxTypeRows.map((row, idx) => (
|
|
<div key={row._id} className="flex gap-2 items-center">
|
|
<span className="text-xs text-muted-foreground w-4 shrink-0">{idx + 1}</span>
|
|
<Select value={row.tax_type_name} onValueChange={(v) => {
|
|
setTaxTypeRows((prev) => prev.map((r) => r._id === row._id ? { ...r, tax_type_name: v } : r));
|
|
}}>
|
|
<SelectTrigger className="h-9 text-[13px] flex-1"><SelectValue placeholder="세금유형 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{taxTypeOptions.map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
value={row.rate}
|
|
onChange={(e) => {
|
|
const v = e.target.value.replace(/[^\d.]/g, "");
|
|
setTaxTypeRows((prev) => prev.map((r) => r._id === row._id ? { ...r, rate: v } : r));
|
|
}}
|
|
placeholder="세율 %"
|
|
className="h-9 text-[13px] w-[80px] text-right"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">%</span>
|
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0"
|
|
onClick={() => setTaxTypeRows((prev) => prev.filter((r) => r._id !== row._id))}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* 담당자 관리 탭 */}
|
|
<TabsContent value="contacts" className="flex flex-col flex-1 overflow-hidden mt-0">
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
{/* 담당자 목록 */}
|
|
<div className="flex items-center justify-between px-6 py-2.5 border-b bg-muted/30 shrink-0">
|
|
<span className="text-xs text-muted-foreground font-medium">
|
|
담당자 {modalContacts.length}명
|
|
</span>
|
|
<Button size="sm" onClick={() => {
|
|
setModalContactForm({});
|
|
setModalContactEditId(null);
|
|
setModalContactFormOpen(true);
|
|
}}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 담당자 추가
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
{modalContactLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : modalContacts.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<Users className="w-8 h-8 mb-2 opacity-30" />
|
|
<span className="text-sm">등록된 담당자가 없어요</span>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted h-10">
|
|
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자명</TableHead>
|
|
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">전화번호</TableHead>
|
|
<TableHead className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이메일</TableHead>
|
|
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">부서</TableHead>
|
|
<TableHead className="w-[60px] text-center 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-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">관리</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{[...modalContacts].sort((a, b) => {
|
|
const aMain = a.is_main === "Y" || a.is_main === true ? 0 : 1;
|
|
const bMain = b.is_main === "Y" || b.is_main === true ? 0 : 1;
|
|
return aMain - bMain;
|
|
}).map((c) => (
|
|
<TableRow key={c._localId || c.id} className="h-[41px]">
|
|
<TableCell className="text-sm font-medium">{c.contact_name}</TableCell>
|
|
<TableCell className="text-[13px]">{c.contact_phone}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{c.contact_email}</TableCell>
|
|
<TableCell className="text-[13px]">{c.department}</TableCell>
|
|
<TableCell className="text-center">
|
|
<button
|
|
className={cn(
|
|
"inline-flex items-center gap-1 text-[11px] font-bold px-3 py-1 rounded-full transition-all cursor-pointer border-2",
|
|
(c.is_main === "Y" || c.is_main === true)
|
|
? "bg-primary text-primary-foreground border-primary shadow-sm shadow-primary/30"
|
|
: "bg-transparent text-muted-foreground border-muted-foreground/20 hover:border-primary/50 hover:text-primary"
|
|
)}
|
|
onClick={async () => {
|
|
const isCurrentMain = c.is_main === "Y" || c.is_main === true;
|
|
if (isCurrentMain) {
|
|
// 메인 해제
|
|
setModalContacts((prev) => prev.map((item) =>
|
|
(item._localId || item.id) === (c._localId || c.id) ? { ...item, is_main: "N" } : item
|
|
));
|
|
} else {
|
|
// 메인 설정 — 기존 메인이 있으면 확인
|
|
const existingMain = modalContacts.find((x) => (x.is_main === "Y" || x.is_main === true) && (x._localId || x.id) !== (c._localId || c.id));
|
|
if (existingMain) {
|
|
const ok = await confirm(`현재 메인 담당자는 "${existingMain.contact_name}"입니다. 변경하시겠습니까?`);
|
|
if (!ok) return;
|
|
}
|
|
setModalContacts((prev) => prev.map((item) =>
|
|
(item._localId || item.id) === (c._localId || c.id)
|
|
? { ...item, is_main: "Y" }
|
|
: { ...item, is_main: "N" }
|
|
));
|
|
}
|
|
}}
|
|
>
|
|
{(c.is_main === "Y" || c.is_main === true) ? "★ 메인" : "메인"}
|
|
</button>
|
|
</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{c.memo}</TableCell>
|
|
<TableCell className="text-center">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button
|
|
variant="ghost" size="sm" className="h-7 w-7 p-0"
|
|
onClick={() => {
|
|
setModalContactForm({ ...c });
|
|
setModalContactEditId(c._localId || c.id);
|
|
setModalContactFormOpen(true);
|
|
}}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive"
|
|
onClick={() => handleModalContactDelete(c._localId || c.id)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
|
|
{/* 담당자 폼 (인라인) */}
|
|
{modalContactFormOpen && (
|
|
<div className="border-t bg-card px-6 py-4 shrink-0">
|
|
<div className="text-sm font-semibold mb-3">{modalContactEditId ? "담당자 수정" : "담당자 추가"}</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">담당자명 <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
value={modalContactForm.contact_name || ""}
|
|
onChange={(e) => setModalContactForm((p) => ({ ...p, contact_name: e.target.value }))}
|
|
placeholder="담당자명"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">전화번호</Label>
|
|
<Input
|
|
value={modalContactForm.contact_phone || ""}
|
|
onChange={(e) => {
|
|
const formatted = formatField("phone", e.target.value);
|
|
setModalContactForm((p) => ({ ...p, contact_phone: formatted }));
|
|
}}
|
|
placeholder="010-0000-0000"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">이메일</Label>
|
|
<Input
|
|
value={modalContactForm.contact_email || ""}
|
|
onChange={(e) => setModalContactForm((p) => ({ ...p, contact_email: e.target.value }))}
|
|
placeholder="example@email.com"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">부서</Label>
|
|
<Input
|
|
value={modalContactForm.department || ""}
|
|
onChange={(e) => setModalContactForm((p) => ({ ...p, department: e.target.value }))}
|
|
placeholder="부서명"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">메모</Label>
|
|
<Input
|
|
value={modalContactForm.memo || ""}
|
|
onChange={(e) => setModalContactForm((p) => ({ ...p, memo: e.target.value }))}
|
|
placeholder="메모"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 flex flex-col justify-end">
|
|
<label className="flex items-center gap-2 cursor-pointer h-8">
|
|
<input
|
|
type="checkbox"
|
|
checked={modalContactForm.is_main === "Y" || modalContactForm.is_main === true}
|
|
onChange={async (e) => {
|
|
const checked = e.target.checked;
|
|
if (checked) {
|
|
const existingMain = modalContacts.find((x) => (x.is_main === "Y" || x.is_main === true) && (x._localId || x.id) !== modalContactEditId);
|
|
if (existingMain) {
|
|
const ok = await confirm(`현재 메인 담당자는 "${existingMain.contact_name}"입니다. 변경하시겠습니까?`);
|
|
if (!ok) return;
|
|
setModalContacts((prev) => prev.map((item) => ({ ...item, is_main: "N" })));
|
|
}
|
|
setModalContactForm((p) => ({ ...p, is_main: "Y" }));
|
|
} else {
|
|
setModalContactForm((p) => ({ ...p, is_main: "N" }));
|
|
}
|
|
}}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-sm">메인 담당자</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 mt-3">
|
|
<Button
|
|
variant="outline" size="sm"
|
|
onClick={() => { setModalContactFormOpen(false); setModalContactForm({}); setModalContactEditId(null); }}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button size="sm" onClick={() => handleModalContactSave()}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" />
|
|
{modalContactEditId ? "수정" : "추가"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ── 탭3: 납품처 관리 ── */}
|
|
<TabsContent value="delivery" className="flex flex-col flex-1 overflow-hidden mt-0">
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
{/* 납품처 목록 헤더 */}
|
|
<div className="flex items-center justify-between px-6 py-2.5 border-b bg-muted/30 shrink-0">
|
|
<span className="text-xs text-muted-foreground font-medium">
|
|
납품처 {modalDeliveries.length}개
|
|
</span>
|
|
<Button size="sm" onClick={async () => {
|
|
const code = await generateDeliveryCode();
|
|
setModalDeliveryForm({ destination_code: code });
|
|
setModalDeliveryEditId(null);
|
|
setModalDeliveryFormErrors({});
|
|
setModalDeliveryFormOpen(true);
|
|
}}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 납품처 추가
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
{modalDeliveryLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : modalDeliveries.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<MapPin className="w-8 h-8 mb-2 opacity-30" />
|
|
<span className="text-sm">등록된 납품처가 없어요</span>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted h-10">
|
|
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납품처코드</TableHead>
|
|
<TableHead className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납품처명</TableHead>
|
|
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">주소</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>
|
|
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">전화번호</TableHead>
|
|
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>
|
|
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메인</TableHead>
|
|
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">관리</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{[...modalDeliveries].sort((a, b) => {
|
|
const aMain = a.is_default === "Y" || a.is_default === true ? 0 : 1;
|
|
const bMain = b.is_default === "Y" || b.is_default === true ? 0 : 1;
|
|
return aMain - bMain;
|
|
}).map((d) => (
|
|
<TableRow key={d._localId || d.id || d.destination_code} className="h-[41px]">
|
|
<TableCell className="text-[13px] font-mono text-muted-foreground">{d.destination_code}</TableCell>
|
|
<TableCell className="text-sm">{d.destination_name}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{d.address}</TableCell>
|
|
<TableCell className="text-[13px]">{d.manager_name}</TableCell>
|
|
<TableCell className="text-[13px]">{d.phone}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{d.memo}</TableCell>
|
|
<TableCell className="text-center">
|
|
<button
|
|
className={cn(
|
|
"inline-flex items-center gap-1 text-[11px] font-bold px-3 py-1 rounded-full transition-all cursor-pointer border-2",
|
|
(d.is_default === "Y" || d.is_default === true)
|
|
? "bg-primary text-primary-foreground border-primary shadow-sm shadow-primary/30"
|
|
: "bg-transparent text-muted-foreground border-muted-foreground/20 hover:border-primary/50 hover:text-primary"
|
|
)}
|
|
onClick={async () => {
|
|
const isCurrentMain = d.is_default === "Y" || d.is_default === true;
|
|
if (isCurrentMain) {
|
|
setModalDeliveries((prev) => prev.map((item) =>
|
|
(item._localId || item.id) === (d._localId || d.id) ? { ...item, is_default: "N" } : item
|
|
));
|
|
} else {
|
|
const existingMain = modalDeliveries.find((x) => (x.is_default === "Y" || x.is_default === true) && (x._localId || x.id) !== (d._localId || d.id));
|
|
if (existingMain) {
|
|
const ok = await confirm(`현재 메인 납품처는 "${existingMain.destination_name}"입니다. 변경하시겠습니까?`);
|
|
if (!ok) return;
|
|
}
|
|
setModalDeliveries((prev) => prev.map((item) =>
|
|
(item._localId || item.id) === (d._localId || d.id)
|
|
? { ...item, is_default: "Y" }
|
|
: { ...item, is_default: "N" }
|
|
));
|
|
}
|
|
}}
|
|
>
|
|
{(d.is_default === "Y" || d.is_default === true) ? "★ 메인" : "메인"}
|
|
</button>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button
|
|
variant="ghost" size="sm" className="h-7 w-7 p-0"
|
|
onClick={() => {
|
|
setModalDeliveryForm({ ...d });
|
|
setModalDeliveryEditId(d._localId || d.id);
|
|
setModalDeliveryFormErrors({});
|
|
setModalDeliveryFormOpen(true);
|
|
}}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive"
|
|
onClick={() => handleModalDeliveryDelete(d._localId || d.id)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
|
|
{/* 납품처 폼 (인라인) */}
|
|
{modalDeliveryFormOpen && (
|
|
<div className="border-t bg-card px-6 py-4 shrink-0">
|
|
<div className="text-sm font-semibold mb-3">{modalDeliveryEditId ? "납품처 수정" : "납품처 추가"}</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">납품처코드</Label>
|
|
<Input
|
|
value={modalDeliveryForm.destination_code || ""}
|
|
onChange={(e) => setModalDeliveryForm((p) => ({ ...p, destination_code: e.target.value }))}
|
|
placeholder="자동 생성"
|
|
className={cn("h-8 text-sm font-mono", modalDeliveryForm.destination_code && "bg-muted")}
|
|
readOnly={!!modalDeliveryForm.destination_code && !modalDeliveryEditId}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">납품처명 <span className="text-destructive">*</span></Label>
|
|
<Input
|
|
value={modalDeliveryForm.destination_name || ""}
|
|
onChange={(e) => setModalDeliveryForm((p) => ({ ...p, destination_name: e.target.value }))}
|
|
placeholder="납품처명"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">담당자</Label>
|
|
<Input
|
|
value={modalDeliveryForm.manager_name || ""}
|
|
onChange={(e) => setModalDeliveryForm((p) => ({ ...p, manager_name: e.target.value }))}
|
|
placeholder="담당자"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs">주소</Label>
|
|
<Input
|
|
value={modalDeliveryForm.address || ""}
|
|
onChange={(e) => setModalDeliveryForm((p) => ({ ...p, address: e.target.value }))}
|
|
placeholder="주소"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">전화번호</Label>
|
|
<Input
|
|
value={modalDeliveryForm.phone || ""}
|
|
onChange={(e) => {
|
|
const formatted = formatField("phone", e.target.value);
|
|
setModalDeliveryForm((p) => ({ ...p, phone: formatted }));
|
|
const err = validateField("phone", formatted);
|
|
setModalDeliveryFormErrors((p) => {
|
|
const n = { ...p };
|
|
if (err) n.phone = err; else delete n.phone;
|
|
return n;
|
|
});
|
|
}}
|
|
placeholder="010-0000-0000"
|
|
className={cn("h-8 text-sm", modalDeliveryFormErrors.phone && "border-destructive")}
|
|
/>
|
|
{modalDeliveryFormErrors.phone && <p className="text-xs text-destructive">{modalDeliveryFormErrors.phone}</p>}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">메모</Label>
|
|
<Input
|
|
value={modalDeliveryForm.memo || ""}
|
|
onChange={(e) => setModalDeliveryForm((p) => ({ ...p, memo: e.target.value }))}
|
|
placeholder="메모"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col justify-end">
|
|
<label className="flex items-center gap-2 cursor-pointer h-8">
|
|
<input
|
|
type="checkbox"
|
|
checked={modalDeliveryForm.is_default === "Y" || modalDeliveryForm.is_default === true}
|
|
onChange={async (e) => {
|
|
const checked = e.target.checked;
|
|
if (checked) {
|
|
const existingMain = modalDeliveries.find((x) => (x.is_default === "Y" || x.is_default === true) && (x._localId || x.id) !== modalDeliveryEditId);
|
|
if (existingMain) {
|
|
const ok = await confirm(`현재 메인 납품처는 "${existingMain.destination_name}"입니다. 변경하시겠습니까?`);
|
|
if (!ok) return;
|
|
setModalDeliveries((prev) => prev.map((item) => ({ ...item, is_default: "N" })));
|
|
}
|
|
setModalDeliveryForm((p) => ({ ...p, is_default: "Y" }));
|
|
} else {
|
|
setModalDeliveryForm((p) => ({ ...p, is_default: "N" }));
|
|
}
|
|
}}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-sm">메인 납품처로 설정</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 mt-3">
|
|
<Button
|
|
variant="outline" size="sm"
|
|
onClick={() => { setModalDeliveryFormOpen(false); setModalDeliveryForm({}); setModalDeliveryEditId(null); setModalDeliveryFormErrors({}); }}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button size="sm" onClick={() => handleModalDeliverySave()}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" />
|
|
{modalDeliveryEditId ? "수정" : "추가"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
<div className="border-t px-6 py-3 flex items-center gap-2 shrink-0">
|
|
{!supplierEditMode && (
|
|
<label className="flex items-center gap-2 cursor-pointer mr-auto">
|
|
<input type="checkbox" checked={continuousInput} onChange={(e) => setContinuousInput(e.target.checked)} className="rounded" />
|
|
<span className="text-sm font-medium">연속입력</span>
|
|
</label>
|
|
)}
|
|
<Button variant="outline" onClick={() => setSupplierModalOpen(false)}>닫기</Button>
|
|
<Button onClick={handleSupplierSave} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
|
{supplierEditMode ? "저장" : "등록"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── 모달: 품목 선택 (1단계) ── */}
|
|
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>품목 선택</DialogTitle>
|
|
<DialogDescription>공급업체에 추가할 품목을 선택하세요. (전체: {itemTotalCount}건 / 대상: {itemSearchResults.length}건{itemCheckedIds.size > 0 ? ` / 선택: ${itemCheckedIds.size}건` : ""})</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2 mb-3">
|
|
<Input
|
|
placeholder="품명/품목코드 검색"
|
|
value={itemSearchKeyword}
|
|
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
|
className="h-9 flex-1"
|
|
/>
|
|
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
|
|
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
|
</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 h-10">
|
|
<TableHead className="w-[40px] text-center px-2">
|
|
<input
|
|
type="checkbox"
|
|
className="rounded"
|
|
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
|
|
onChange={(e) => {
|
|
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
|
|
else setItemCheckedIds(new Set());
|
|
}}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
|
<TableHead className="min-w-[140px] 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-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{itemSearchResults.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8 text-sm">
|
|
검색 결과가 없어요
|
|
</TableCell>
|
|
</TableRow>
|
|
) : itemSearchResults.map((item) => (
|
|
<TableRow
|
|
key={item.id}
|
|
className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
|
|
onClick={() => setItemCheckedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
|
return next;
|
|
})}
|
|
>
|
|
<TableCell className="text-center px-2">
|
|
<input type="checkbox" className="rounded" checked={itemCheckedIds.has(item.id)} readOnly />
|
|
</TableCell>
|
|
<TableCell className="text-[13px] font-mono">
|
|
<span className="block truncate" title={item.item_number}>{item.item_number}</span>
|
|
</TableCell>
|
|
<TableCell className="text-sm">{item.item_name}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<DialogFooter className="flex items-center justify-between w-full">
|
|
<span className="text-sm text-muted-foreground">{itemCheckedIds.size}개 선택됨</span>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setItemSelectOpen(false)}>취소</Button>
|
|
<Button onClick={goToItemDetail} disabled={itemCheckedIds.size === 0}>
|
|
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}개 다음
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── 모달: 품목 상세 입력 (2단계) ── */}
|
|
<Dialog open={itemDetailOpen} onOpenChange={setItemDetailOpen}>
|
|
<DialogContent className="max-w-[1100px] h-[85vh] flex flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
품목 상세정보 {editItemData ? "수정" : "입력"} — {selectedSupplier?.supplier_name || ""}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{editItemData
|
|
? "거래처 품번/품명과 기간별 단가를 수정합니다."
|
|
: "선택한 품목의 거래처 품번/품명과 기간별 단가를 설정합니다."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-6 py-2 flex-1 overflow-y-auto">
|
|
{selectedItemsForDetail.map((item, idx) => {
|
|
const itemKey = item.item_number || item.id;
|
|
const mappingRows = itemMappings[itemKey] || [];
|
|
const prices = itemPrices[itemKey] || [];
|
|
|
|
return (
|
|
<div key={itemKey} className="border rounded-xl overflow-hidden bg-card">
|
|
{/* 품목 헤더 */}
|
|
<div className="px-5 py-3 bg-muted/30 border-b">
|
|
<div className="font-bold">{idx + 1}. {item.item_name || itemKey}</div>
|
|
<div className="text-xs text-muted-foreground">{itemKey} | {item.size || ""} | {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit || ""}</div>
|
|
</div>
|
|
|
|
<div className="flex gap-4 p-5 items-stretch">
|
|
{/* 좌: 거래처 품번/품명 */}
|
|
<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">
|
|
<Tag className="w-3.5 h-3.5 text-primary" /> 거래처 품번/품명 관리
|
|
</span>
|
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(itemKey)}>
|
|
<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(itemKey, 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(itemKey, 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(itemKey, 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(itemKey, 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(itemKey)}>
|
|
<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(itemKey, 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(itemKey, price._id, "start_date", v);
|
|
if (price.end_date && v > price.end_date) {
|
|
updatePriceRow(itemKey, 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(itemKey, 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(itemKey, 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(itemKey, 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(itemKey, 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(itemKey, 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(itemKey, price._id, "discount_value", raw);
|
|
}}
|
|
className="h-9 text-[13px] text-right"
|
|
placeholder="0"
|
|
/>
|
|
<Select
|
|
value={price.rounding_unit_value}
|
|
onValueChange={(v) => updatePriceRow(itemKey, 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(itemKey, 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={() => {
|
|
setItemDetailOpen(false);
|
|
if (!editItemData) setItemSelectOpen(true);
|
|
setEditItemData(null);
|
|
}}
|
|
>
|
|
{editItemData ? "취소" : "← 이전"}
|
|
</Button>
|
|
<Button onClick={handleItemDetailSave} 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>
|
|
|
|
{/* 엑셀 업로드 (멀티테이블) */}
|
|
{excelChainConfig && (
|
|
<MultiTableExcelUploadModal
|
|
open={excelUploadOpen}
|
|
onOpenChange={(open) => {
|
|
setExcelUploadOpen(open);
|
|
if (!open) setExcelChainConfig(null);
|
|
}}
|
|
config={excelChainConfig}
|
|
onSuccess={() => {
|
|
fetchSuppliers();
|
|
const cid = selectedSupplierId;
|
|
setSelectedSupplierId(null);
|
|
setTimeout(() => setSelectedSupplierId(cid), 50);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 테이블 설정 모달 */}
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
|
|
{ConfirmDialogComponent}
|
|
</div>
|
|
);
|
|
}
|