Files
pipeline/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx
T
kjs 972a0143ad refactor: Integrate ImageUpload component for mold image handling
- Replaced manual image upload logic with the ImageUpload component for better management of mold images.
- Updated image source handling to ensure proper display of images based on their URL format.
- Enhanced error handling for image display to improve user experience.

These changes aim to streamline the image upload process and enhance the overall functionality of the mold information page across multiple companies.
2026-04-11 14:50:18 +09:00

2770 lines
148 KiB
TypeScript

"use client";
/**
* 거래처관리 — Type B 마스터-디테일 레이아웃 (리디자인)
*
* 좌측: 거래처 목록 (customer_mng)
* 우측: 품목별 단가 + 납품처 정보 탭
*
* 모달:
* - 거래처 등록/수정 (customer_mng)
* - 품목 추가 (item_info 검색 → customer_item_mapping + customer_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 CUSTOMER_TABLE = "customer_mng";
const MAPPING_TABLE = "customer_item_mapping";
const PRICE_TABLE = "customer_item_prices";
const DELIVERY_TABLE = "delivery_destination";
const CONTACT_TABLE = "customer_contact";
const CUSTOMER_GRID_COLUMNS = [
{ key: "customer_code", label: "거래처코드" },
{ key: "customer_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 CustomerManagementPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog();
const ts = useTableSettings("c16-customer", CUSTOMER_TABLE, CUSTOMER_GRID_COLUMNS);
const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 좌측: 거래처 목록
const [customers, setCustomers] = useState<any[]>([]);
const [rawCustomers, setRawCustomers] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [mainContactMap, setMainContactMap] = useState<Record<string, any>>({});
const [customerCount, setCustomerCount] = useState(0);
const [selectedCustomerId, setSelectedCustomerId] = 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 [customerModalOpen, setCustomerModalOpen] = useState(false);
const [customerEditMode, setCustomerEditMode] = useState(false);
const [customerForm, setCustomerForm] = 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; customer_item_code: string; customer_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 [customerModalTab, setCustomerModalTab] = useState<"basic" | "contacts" | "delivery">("basic");
// 담당자 (customer_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/${CUSTOMER_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/customer_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 fetchCustomers = useCallback(async () => {
setCustomerLoading(true);
try {
const filters = searchFilters.map(f => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "customer_code", order: "desc" },
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setRawCustomers(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.customer_code || "").replace(/\D/g, ""), 10) || 0;
const bNum = parseInt((b.customer_code || "").replace(/\D/g, ""), 10) || 0;
return bNum - aNum;
});
setCustomers(data);
setCustomerCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("거래처 조회 실패:", err);
toast.error("거래처 목록을 불러오는데 실패했습니다.");
} finally {
setCustomerLoading(false);
}
}, [searchFilters, categoryOptions, employeeOptions, mainContactMap]);
useEffect(() => { fetchCustomers(); }, [fetchCustomers]);
// 메인 담당자 조회 (최초 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.customer_id) {
map[c.customer_id] = c;
}
}
setMainContactMap(map);
} catch { /* skip */ }
}, []);
useEffect(() => { fetchMainContacts(); }, [fetchMainContacts]);
const selectedCustomer = customers.find((c) => c.id === selectedCustomerId);
// 선택된 거래처의 품목 단가 조회
useEffect(() => {
if (!selectedCustomer?.customer_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: "customer_id", operator: "equals", value: selectedCustomer.customer_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: "customer_id", operator: "equals", value: selectedCustomer.customer_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 now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "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();
}, [selectedCustomer?.customer_code]);
// 납품처 조회
useEffect(() => {
if (!selectedCustomer?.customer_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: "customer_code", operator: "equals", value: selectedCustomer.customer_code },
]},
autoFilter: true,
});
setDeliveryItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setDeliveryItems([]); } finally { setDeliveryLoading(false); }
};
fetchDelivery();
}, [selectedCustomer?.customer_code]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 모달 내 담당자 목록 조회
const fetchModalContacts = useCallback(async (customerId: string) => {
setModalContactLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, {
page: 1, size: 200,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: customerId }] },
autoFilter: true,
});
setModalContacts(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setModalContacts([]); } finally { setModalContactLoading(false); }
}, []);
// 모달 내 납품처 목록 조회
const fetchModalDeliveries = useCallback(async (customerCode: string) => {
setModalDeliveryLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, {
page: 1, size: 200,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
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 openCustomerRegister = async () => {
setCustomerForm({});
setFormErrors({});
setCustomerEditMode(false);
setCustomerModalTab("basic");
setModalContacts([]);
setModalDeliveries([]);
setModalContactFormOpen(false);
setModalDeliveryFormOpen(false);
setTaxTypeRows([]);
setCustomerModalOpen(true);
// 거래처 코드 자동 채번 — 기존 데이터 max값 기반
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${CUSTOMER_TABLE}/customer_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/${CUSTOMER_TABLE}/data`, {
page: 1, size: 500, autoFilter: true,
sort: { columnName: "customer_code", order: "desc" },
});
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
let maxSeq = 0;
for (const row of allRows) {
const code = row.customer_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");
setCustomerForm((prev) => ({ ...prev, customer_code: nextCode, _numberingRuleId: ruleId }));
}
}
} catch { /* skip */ }
};
const openCustomerEdit = () => {
if (!selectedCustomer) return;
const rawData = rawCustomers.find((c) => c.id === selectedCustomerId);
setCustomerForm({ ...(rawData || selectedCustomer) });
setFormErrors({});
setCustomerEditMode(true);
setCustomerModalTab("basic");
setModalContactFormOpen(false);
setModalDeliveryFormOpen(false);
setModalContactForm({});
setModalDeliveryForm({});
setModalContactEditId(null);
setModalDeliveryEditId(null);
// 수정 모드에서는 바로 조회
const code = (rawData || selectedCustomer).customer_code;
const id = (rawData || selectedCustomer).id;
if (id) {
fetchModalContacts(id);
// 세금유형 로드
apiClient.post(`/table-management/tables/customer_tax_type/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "customer_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);
setCustomerModalOpen(true);
};
// 폼 필드 변경 시 자동 포맷팅 + 실시간 검증
const handleFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setCustomerForm((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 (customerId: string, customerCode: string) => {
// 세금유형 — 기존 삭제 후 재생성
try {
const existTax = await apiClient.post(`/table-management/tables/customer_tax_type/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: customerId }] },
autoFilter: true,
});
const existRows = existTax.data?.data?.data || existTax.data?.data?.rows || [];
if (existRows.length > 0) {
await apiClient.delete(`/table-management/tables/customer_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/customer_tax_type/add`, {
id: crypto.randomUUID(), customer_id: customerId,
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: "customer_id", operator: "equals", value: customerId }] },
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(), customer_id: customerId,
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: "customer_code", operator: "equals", value: customerCode }] },
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(), customer_code: customerCode,
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 handleCustomerSave = async () => {
if (!customerForm.customer_name) { toast.error("거래처명은 필수입니다."); return; }
if (!customerForm.status) { toast.error("상태는 필수입니다."); return; }
const errors = validateForm(customerForm, ["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 } = customerForm;
const cleanFields: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {
cleanFields[key] = value === "" ? null : value;
}
if (customerEditMode && id) {
// 수정
await apiClient.put(`/table-management/tables/${CUSTOMER_TABLE}/edit`, {
originalData: { id }, updatedData: cleanFields,
});
await saveSubTables(id, cleanFields.customer_code || customerForm.customer_code);
toast.success("저장되었습니다.");
} else {
// 신규 등록
await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields);
// id 획득
const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: cleanFields.customer_code }] },
autoFilter: true,
});
const newRow = (res.data?.data?.data || res.data?.data?.rows || [])[0];
if (newRow?.id) {
await saveSubTables(newRow.id, cleanFields.customer_code);
}
toast.success("거래처가 등록되었습니다.");
}
fetchCustomers();
fetchMainContacts();
if (!customerEditMode && continuousInput) {
// 연속입력 — 폼 초기화하고 모달 유지
setCustomerForm({});
setModalContacts([]);
setModalDeliveries([]);
setTaxTypeRows([]);
setCustomerModalTab("basic");
// 새 코드 채번
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${CUSTOMER_TABLE}/customer_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
const allRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "customer_code", order: "desc" } });
const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || [];
let maxSeq = 0;
for (const row of allRows) { const match = (row.customer_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;
setCustomerForm({ customer_code: prefix + String(maxSeq + 1).padStart(seqLen, "0") });
}
}
} catch { /* skip */ }
toast.success("등록 완료. 다음 거래처를 입력하세요.");
} else {
setCustomerModalOpen(false);
// 우측 패널 갱신
if (selectedCustomerId) {
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
}
}
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 거래처 삭제
const handleCustomerDelete = async () => {
if (!selectedCustomerId) return;
const ok = await confirm("거래처를 삭제하시겠습니까?", {
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${CUSTOMER_TABLE}/delete`, {
data: [{ id: selectedCustomerId }],
});
toast.success("삭제되었습니다.");
setSelectedCustomerId(null);
fetchCustomers();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 품목 검색
const searchItems = async () => {
setItemSearchLoading(true);
try {
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
const filters: any[] = salesCode
? [{ columnName: "division", operator: "contains", value: salesCode }]
: [];
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: { enabled: true, filters },
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 seenNumbers = new Set<string>();
const deduped = allItems.filter((item: any) => {
if (existingItemIds.has(item.item_number) || existingItemIds.has(item.id)) return false;
if (item.item_number && seenNumbers.has(item.item_number)) return false;
if (item.item_number) seenNumbers.add(item.item_number);
return true;
});
setItemSearchResults(deduped);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
// 품목 선택 완료 → 상세 입력 모달로 전환
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()}`, customer_item_code: "", customer_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: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
.filter((m: any) => m.customer_item_code || m.customer_item_name)
.map((m: any) => ({
_id: `m_existing_${m.id}`,
customer_item_code: m.customer_item_code || "",
customer_item_name: m.customer_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: "customer_id", operator: "equals", value: selectedCustomer!.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
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 (!selectedCustomer) 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: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
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: {
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_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(),
customer_id: selectedCustomer.customer_code, item_id: itemKey,
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_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: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
existingPriceRows = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
} catch { /* skip */ }
// 단가 upsert
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
const usedPriceIds = new Set<string>();
for (let pi = 0; pi < priceRows.length; pi++) {
const price = priceRows[pi];
const priceData = {
mapping_id: firstMappingId || editItemData.id,
customer_id: selectedCustomer.customer_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]?.customer_item_code) {
const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_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(),
customer_id: selectedCustomer.customer_code, item_id: itemKey,
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_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(),
customer_id: selectedCustomer.customer_code, item_id: itemKey,
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
});
}
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
id: crypto.randomUUID(),
mapping_id: mappingId || "", customer_id: selectedCustomer.customer_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 = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
savingRef.current = false;
}
};
// 품목 매핑 삭제
const handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑을 삭제하시겠습니까?`, {
description: "관련된 단가 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
for (const mappingId of priceCheckedIds) {
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] },
autoFilter: true,
});
const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
if (prices.length > 0) {
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
data: prices.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
}
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: priceCheckedIds.map((id) => ({ id })),
});
toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
setPriceCheckedIds([]);
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
} catch {
toast.error("삭제에 실패했습니다.");
}
};
// EDataTable 컬럼 정의 (거래처 목록) — ts.visibleColumns 순서를 따름
const customerColumns: EDataTableColumn[] = useMemo(() => {
const colProps: Record<string, Partial<EDataTableColumn>> = {
customer_code: { width: "w-[120px]" },
customer_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 (customers.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 customers) {
const custMappings = allMappings.filter((m) => m.customer_id === c.customer_code);
if (custMappings.length === 0) {
rows.push({
거래처코드: c.customer_code, 거래처명: c.customer_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 custMappings) {
rows.push({
거래처코드: c.customer_code, 거래처명: c.customer_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.customer_item_code || "", 거래처품명: m.customer_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={CUSTOMER_TABLE}
filterId="c16-customer"
onFilterChange={setSearchFilters}
dataCount={customers.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(CUSTOMER_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">
{customerCount}
</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={openCustomerRegister}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedCustomerId} onClick={openCustomerEdit}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedCustomerId} onClick={handleCustomerDelete}>
<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={customerColumns}
data={ts.groupData(showInactive ? customers : customers.filter((c) => c.status !== "거래정지"))}
rowKey={(row) => row.id}
loading={customerLoading}
emptyMessage="등록된 거래처가 없어요"
selectedId={selectedCustomerId}
onSelect={(id) => setSelectedCustomerId(id)}
onRowDoubleClick={(row) => { setSelectedCustomerId(row.id); openCustomerEdit(); }}
showRowNumber
showPagination
defaultPageSize={20}
draggableColumns={false}
columnOrderKey="c16-customer"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 디테일 패널 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
{!selectedCustomerId ? (
/* 빈 상태 */
<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={openCustomerEdit}>
<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.customer_item_code}</TableCell>
<TableCell className="text-[13px]">{m.customer_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={customerModalOpen} onOpenChange={(open) => {
if (!open && isConfirmOpenRef.current) return;
setCustomerModalOpen(open);
if (!open) {
setModalContactFormOpen(false);
setModalDeliveryFormOpen(false);
setModalContactForm({});
setModalDeliveryForm({});
setModalContactEditId(null);
setModalDeliveryEditId(null);
fetchCustomers();
if (customerForm.customer_code) {
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(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>{customerEditMode ? "거래처 수정" : "거래처 등록"}</DialogTitle>
<DialogDescription>
{customerEditMode ? "거래처 정보를 수정합니다." : "새로운 거래처를 등록합니다."}
</DialogDescription>
</DialogHeader>
<Tabs value={customerModalTab} onValueChange={(v: any) => setCustomerModalTab(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={customerForm.customer_code || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, customer_code: e.target.value }))}
placeholder={customerEditMode ? "" : "자동 생성"}
className={cn("h-9 font-mono", !customerEditMode && customerForm.customer_code && "bg-muted")}
readOnly={!customerEditMode && !!customerForm.customer_code}
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input
value={customerForm.customer_name || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, customer_name: e.target.value }))}
placeholder="거래처명"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select
value={customerForm.division || "__none__"}
onValueChange={(v) => setCustomerForm((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={customerForm.status || "__none__"}
onValueChange={(v) => setCustomerForm((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={customerForm.contact_person || ""}
onChange={(e) => setCustomerForm((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={customerForm.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={customerForm.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={customerForm.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={customerForm.address || ""}
onChange={(e) => setCustomerForm((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">
{!customerEditMode && (
<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={() => setCustomerModalOpen(false)}></Button>
<Button onClick={handleCustomerSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
{customerEditMode ? "저장" : "등록"}
</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 ? "수정" : "입력"} {selectedCustomer?.customer_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.customer_item_code}
onChange={(e) => updateMappingRow(itemKey, mRow._id, "customer_item_code", e.target.value)}
placeholder="거래처 품번"
className="h-9 text-[13px] flex-1"
/>
<Input
value={mRow.customer_item_name}
onChange={(e) => updateMappingRow(itemKey, mRow._id, "customer_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">&nbsp;</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={() => {
fetchCustomers();
const cid = selectedCustomerId;
setSelectedCustomerId(null);
setTimeout(() => setSelectedCustomerId(cid), 50);
}}
/>
)}
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
{ConfirmDialogComponent}
</div>
);
}