f2f18db449
- Update the logic for retrieving and processing customer item prices. - Replace the previous price mapping with a grouping mechanism based on item_id and today's date. - Enhance the handling of customer item codes and names to ensure proper aggregation. - Improve overall readability and maintainability of the code. This commit enhances the functionality of the customer management page by ensuring accurate price data is displayed based on the current date, improving user experience in managing customer items.
1418 lines
70 KiB
TypeScript
1418 lines
70 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 거래처관리 — 하드코딩 페이지
|
|
*
|
|
* 좌측: 거래처 목록 (customer_mng)
|
|
* 우측: 선택한 거래처의 품목별 단가 정보 (customer_item_prices, entity join → item_info)
|
|
*
|
|
* 모달:
|
|
* - 거래처 등록/수정 (customer_mng)
|
|
* - 품목 추가 (item_info 검색 → customer_item_mapping + customer_item_prices)
|
|
* - 납품처 등록 (delivery_destination)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } 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 {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
|
import {
|
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
|
Users, Package, MapPin, Search, X, Maximize2, Minimize2, Settings2,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { toast } from "sonner";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
|
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 { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
|
|
|
const CUSTOMER_TABLE = "customer_mng";
|
|
const MAPPING_TABLE = "customer_item_mapping";
|
|
const PRICE_TABLE = "customer_item_prices";
|
|
const DELIVERY_TABLE = "delivery_destination";
|
|
|
|
// 좌측: 거래처 목록 컬럼
|
|
const LEFT_COLUMNS: DataGridColumn[] = [
|
|
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
|
|
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
|
|
{ key: "division", label: "거래유형", width: "w-[80px]" },
|
|
{ key: "contact_person", label: "거래처담당자", width: "w-[90px]" },
|
|
{ key: "internal_manager", label: "사내담당자", width: "w-[90px]" },
|
|
{ key: "contact_phone", label: "전화번호", width: "w-[110px]" },
|
|
{ key: "business_number", label: "사업자번호", width: "w-[110px]" },
|
|
{ key: "email", label: "이메일", width: "w-[130px]" },
|
|
{ key: "address", label: "주소", minWidth: "min-w-[150px]" },
|
|
{ key: "status", label: "상태", width: "w-[60px]" },
|
|
];
|
|
|
|
// 우측: 품목별 단가 컬럼
|
|
const RIGHT_COLUMNS: DataGridColumn[] = [
|
|
{ key: "item_number", label: "품목코드", width: "w-[100px]" },
|
|
{ key: "item_name", label: "품명", minWidth: "min-w-[100px]" },
|
|
{ key: "customer_item_code", label: "거래처품번", width: "w-[100px]" },
|
|
{ key: "customer_item_name", label: "거래처품명", width: "w-[100px]" },
|
|
{ key: "base_price_type", label: "기준유형", width: "w-[80px]" },
|
|
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
|
|
{ key: "discount_type", label: "할인유형", width: "w-[70px]" },
|
|
{ key: "discount_value", label: "할인값", width: "w-[60px]", align: "right" },
|
|
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
|
|
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
|
];
|
|
|
|
export default function CustomerManagementPage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
|
|
// 좌측: 거래처 목록
|
|
const [customers, setCustomers] = useState<any[]>([]);
|
|
const [rawCustomers, setRawCustomers] = useState<any[]>([]);
|
|
const [customerLoading, setCustomerLoading] = useState(false);
|
|
const [customerCount, setCustomerCount] = useState(0);
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
|
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(LEFT_COLUMNS);
|
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
|
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
|
|
|
|
// 우측: 탭
|
|
const [rightTab, setRightTab] = useState<"items" | "delivery">("items");
|
|
// 우측: 품목 단가
|
|
const [priceItems, setPriceItems] = useState<any[]>([]);
|
|
const [priceLoading, setPriceLoading] = useState(false);
|
|
// 우측: 납품처
|
|
const [deliveryItems, setDeliveryItems] = useState<any[]>([]);
|
|
|
|
// 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용)
|
|
const [editItemData, setEditItemData] = useState<any>(null);
|
|
const savingRef = useRef(false);
|
|
const [deliveryLoading, setDeliveryLoading] = useState(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 [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 [deliveryModalOpen, setDeliveryModalOpen] = useState(false);
|
|
const [deliveryForm, setDeliveryForm] = useState<Record<string, any>>({});
|
|
|
|
// 엑셀
|
|
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 load = async () => {
|
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
|
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;
|
|
};
|
|
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 */ }
|
|
}
|
|
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);
|
|
};
|
|
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 applyTableSettings = useCallback((settings: TableSettings) => {
|
|
// 컬럼 표시/숨김/순서/너비
|
|
const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c]));
|
|
const applied: DataGridColumn[] = [];
|
|
for (const cs of settings.columns) {
|
|
if (!cs.visible) continue;
|
|
const orig = colMap.get(cs.columnName);
|
|
if (orig) {
|
|
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
|
|
}
|
|
}
|
|
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
|
|
for (const col of LEFT_COLUMNS) {
|
|
if (!settingKeys.has(col.key)) applied.push(col);
|
|
}
|
|
setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS);
|
|
// 필터 설정
|
|
setFilterConfig(settings.filters);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const saved = loadTableSettings("customer-mng");
|
|
if (saved) applyTableSettings(saved);
|
|
}, []);
|
|
|
|
// 거래처 목록 조회
|
|
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,
|
|
});
|
|
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
|
// raw 데이터 보관 (수정 시 원본 카테고리 코드 사용)
|
|
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) => ({
|
|
...r,
|
|
division: resolve("division", r.division),
|
|
status: resolve("status", r.status),
|
|
internal_manager: r.internal_manager
|
|
? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager)
|
|
: "",
|
|
}));
|
|
setCustomers(data);
|
|
setCustomerCount(res.data?.data?.total || raw.length);
|
|
} catch (err) {
|
|
console.error("거래처 조회 실패:", err);
|
|
toast.error("거래처 목록을 불러오는데 실패했습니다.");
|
|
} finally {
|
|
setCustomerLoading(false);
|
|
}
|
|
}, [searchFilters, categoryOptions, employeeOptions]);
|
|
|
|
useEffect(() => { fetchCustomers(); }, [fetchCustomers]);
|
|
|
|
// 선택된 거래처의 품목 단가 조회
|
|
const selectedCustomer = customers.find((c) => c.id === selectedCustomerId);
|
|
|
|
useEffect(() => {
|
|
if (!selectedCustomer?.customer_code) { setPriceItems([]); return; }
|
|
const fetchItems = async () => {
|
|
setPriceLoading(true);
|
|
try {
|
|
// 1. customer_item_mapping 조회 (품목 매핑 — 기본 데이터)
|
|
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 || [];
|
|
|
|
// 2. item_id → item_info 조인 (품명 등)
|
|
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 */ }
|
|
}
|
|
|
|
// 3. customer_item_prices 조회
|
|
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 */ }
|
|
}
|
|
|
|
// 4. 매핑별 행 생성 + 오늘 날짜 기준 단가 + 같은 품목 첫 행만 품목코드/품명 표시
|
|
const priceResolve = (col: string, code: string) => {
|
|
if (!code) return "";
|
|
return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
|
};
|
|
const today = new Date().toISOString().split("T")[0];
|
|
const seenItemIds = new Set<string>();
|
|
|
|
// item_id로 정렬하여 같은 품목끼리 묶기
|
|
const sortedMappings = [...mappings].sort((a: any, b: any) => (a.item_id || "").localeCompare(b.item_id || ""));
|
|
|
|
setPriceItems(sortedMappings.map((m: any) => {
|
|
const itemKey = m.item_id || "";
|
|
const itemInfo = itemMap[itemKey] || {};
|
|
const isFirstOfGroup = !seenItemIds.has(itemKey);
|
|
if (itemKey) seenItemIds.add(itemKey);
|
|
|
|
// 오늘 날짜에 해당하는 단가
|
|
const itemPriceList = allPrices.filter((p: any) => p.item_id === itemKey);
|
|
const todayPrice = itemPriceList.find((p: any) =>
|
|
(!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today)
|
|
) || itemPriceList[0] || {};
|
|
|
|
return {
|
|
...m,
|
|
item_number: isFirstOfGroup ? itemKey : "",
|
|
item_name: isFirstOfGroup ? (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 || ""),
|
|
};
|
|
}));
|
|
} 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 openCustomerRegister = () => {
|
|
setCustomerForm({});
|
|
setFormErrors({});
|
|
setCustomerEditMode(false);
|
|
setCustomerModalOpen(true);
|
|
};
|
|
|
|
const openCustomerEdit = () => {
|
|
if (!selectedCustomer) return;
|
|
// raw 데이터에서 원본 카테고리 코드 가져오기 (라벨 변환 전 데이터)
|
|
const rawData = rawCustomers.find((c) => c.id === selectedCustomerId);
|
|
setCustomerForm({ ...(rawData || selectedCustomer) });
|
|
setFormErrors({});
|
|
setCustomerEditMode(true);
|
|
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 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, ...fields } = customerForm;
|
|
// 빈 문자열을 null로 변환 (DB 타입 호환)
|
|
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,
|
|
});
|
|
toast.success("수정되었습니다.");
|
|
} else {
|
|
await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields);
|
|
toast.success("등록되었습니다.");
|
|
}
|
|
setCustomerModalOpen(false);
|
|
fetchCustomers();
|
|
} catch (err: any) {
|
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// 거래처 삭제
|
|
const handleCustomerDelete = async () => {
|
|
if (!selectedCustomerId) return;
|
|
const ok = await confirm("거래처를 삭제하시겠습니까?", {
|
|
description: "관련된 품목 매핑, 단가, 납품처 정보도 함께 삭제됩니다.",
|
|
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 filters: any[] = [];
|
|
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
|
page: 1, size: 50,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
});
|
|
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
|
|
// 이미 등록된 품목 제외
|
|
const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number));
|
|
setItemSearchResults(allItems.filter((item: any) => !existingItemIds.has(item.item_number) && !existingItemIds.has(item.id)));
|
|
} 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 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 };
|
|
// 단가 자동 계산: base_price - discount
|
|
if (["base_price", "discount_type", "discount_value"].includes(field)) {
|
|
const bp = Number(updated.base_price) || 0;
|
|
const dv = Number(updated.discount_value) || 0;
|
|
const dt = updated.discount_type;
|
|
let calc = bp;
|
|
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100); // 할인율
|
|
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv; // 할인금액
|
|
updated.calculated_price = String(Math.round(calc));
|
|
}
|
|
return updated;
|
|
}),
|
|
}));
|
|
};
|
|
|
|
// 품목 상세 저장 (mapping + prices 일괄)
|
|
// 우측 품목 편집 열기 — 등록과 동일한 상세 입력 모달을 재활용
|
|
const openEditItem = async (row: any) => {
|
|
const itemKey = row.item_number || row.item_id;
|
|
|
|
// item_info에서 품목 정보 조회
|
|
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 */ }
|
|
|
|
// DB에서 해당 품목의 모든 매핑 조회
|
|
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 */ }
|
|
|
|
// DB에서 해당 품목의 모든 기간별 단가 조회
|
|
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) {
|
|
// 편집 모드: 기존 mapping UPDATE
|
|
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
|
originalData: { id: editItemData.id },
|
|
updatedData: {
|
|
customer_item_code: mappingRows[0]?.customer_item_code || "",
|
|
customer_item_name: mappingRows[0]?.customer_item_name || "",
|
|
base_price: null, // prices 테이블로 이동
|
|
discount_type: null,
|
|
discount_value: null,
|
|
calculated_price: null,
|
|
},
|
|
});
|
|
|
|
// 기존 prices 삭제 후 재등록
|
|
try {
|
|
const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
|
|
page: 1, size: 100,
|
|
dataFilter: { enabled: true, filters: [
|
|
{ columnName: "mapping_id", operator: "equals", value: editItemData.id },
|
|
]}, autoFilter: true,
|
|
});
|
|
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
|
|
if (existing.length > 0) {
|
|
await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, {
|
|
data: existing.map((p: any) => ({ id: p.id })),
|
|
});
|
|
}
|
|
} catch { /* skip */ }
|
|
|
|
// 새 prices INSERT
|
|
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`, {
|
|
mapping_id: 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,
|
|
});
|
|
}
|
|
} 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`, {
|
|
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`, {
|
|
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`, {
|
|
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 handleDeliverySave = async () => {
|
|
if (!deliveryForm.destination_name || !selectedCustomer) return;
|
|
try {
|
|
await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/add`, {
|
|
...deliveryForm,
|
|
customer_code: selectedCustomer.customer_code,
|
|
});
|
|
toast.success("납품처가 등록되었습니다.");
|
|
setDeliveryModalOpen(false);
|
|
// 납품처 목록 새로고침
|
|
const cid = selectedCustomerId;
|
|
setSelectedCustomerId(null);
|
|
setTimeout(() => setSelectedCustomerId(cid), 50);
|
|
} catch (err: any) {
|
|
toast.error(err.response?.data?.message || "납품처 등록에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// 엑셀 다운로드
|
|
const handleExcelDownload = async () => {
|
|
if (customers.length === 0) return;
|
|
toast.loading("엑셀 데이터 준비 중...", { id: "excel-dl" });
|
|
try {
|
|
// 전체 거래처의 품목 매핑 + 단가 조회
|
|
const allMappings: any[] = [];
|
|
const custCodes = customers.map((c) => c.customer_code).filter(Boolean);
|
|
|
|
if (custCodes.length > 0) {
|
|
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 || [];
|
|
|
|
// item_id → item_info 조인
|
|
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) {
|
|
// 품목 없는 거래처도 1행
|
|
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");
|
|
console.error("엑셀 다운로드 실패:", err);
|
|
toast.error("다운로드에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
// 셀렉트 렌더링
|
|
const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => (
|
|
<Select value={value || "__none__"} onValueChange={(v) => onChange(v === "__none__" ? "" : v)}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
|
{(categoryOptions[field] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col gap-3 p-3">
|
|
{/* 검색 */}
|
|
<DynamicSearchFilter
|
|
tableName={CUSTOMER_TABLE}
|
|
filterId="customer-mng"
|
|
onFilterChange={setSearchFilters}
|
|
dataCount={customerCount}
|
|
externalFilterConfig={filterConfig}
|
|
extraActions={
|
|
<div className="flex gap-1.5">
|
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-9" 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-9" onClick={handleExcelDownload}>
|
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
|
</Button>
|
|
</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 p-3 border-b bg-muted/10 shrink-0">
|
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
|
<Users className="w-4 h-4" /> 거래처 목록
|
|
<Badge variant="secondary" className="font-normal">{customerCount}건</Badge>
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
<DataGrid
|
|
gridId="customer-left"
|
|
columns={gridColumns}
|
|
data={customers}
|
|
loading={customerLoading}
|
|
selectedId={selectedCustomerId}
|
|
onSelect={setSelectedCustomerId}
|
|
onRowDoubleClick={() => openCustomerEdit()}
|
|
tableName={CUSTOMER_TABLE}
|
|
emptyMessage="등록된 거래처가 없습니다"
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 우측: 탭 (품목 정보 / 납품처) */}
|
|
<ResizablePanel defaultSize={45} minSize={25}>
|
|
<div className="flex flex-col h-full">
|
|
{/* 탭 헤더 */}
|
|
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => setRightTab("items")}
|
|
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors",
|
|
rightTab === "items" ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
|
|
<Package className="w-3.5 h-3.5 inline mr-1" />품목 정보
|
|
</button>
|
|
<button onClick={() => setRightTab("delivery")}
|
|
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors",
|
|
rightTab === "delivery" ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
|
|
<MapPin className="w-3.5 h-3.5 inline mr-1" />납품처
|
|
{deliveryItems.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{deliveryItems.length}</Badge>}
|
|
</button>
|
|
{selectedCustomer && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedCustomer.customer_name}</Badge>}
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
{rightTab === "items" && (
|
|
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
|
|
onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 품목 추가
|
|
</Button>
|
|
)}
|
|
{rightTab === "delivery" && (
|
|
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
|
|
onClick={() => { setDeliveryForm({}); setDeliveryModalOpen(true); }}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 납품처 등록
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 콘텐츠 */}
|
|
{!selectedCustomerId ? (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
|
좌측에서 거래처를 선택하세요
|
|
</div>
|
|
) : rightTab === "items" ? (
|
|
<DataGrid
|
|
gridId="customer-right-items"
|
|
columns={RIGHT_COLUMNS}
|
|
data={priceItems}
|
|
loading={priceLoading}
|
|
showRowNumber={false}
|
|
tableName={MAPPING_TABLE}
|
|
emptyMessage="등록된 품목이 없습니다"
|
|
onRowDoubleClick={(row) => openEditItem(row)}
|
|
/>
|
|
) : (
|
|
<DataGrid
|
|
gridId="customer-right-delivery"
|
|
columns={[
|
|
{ key: "destination_code", label: "납품처코드", width: "w-[110px]" },
|
|
{ key: "destination_name", label: "납품처명", minWidth: "min-w-[130px]" },
|
|
{ key: "address", label: "주소", minWidth: "min-w-[150px]" },
|
|
{ key: "manager_name", label: "담당자", width: "w-[80px]" },
|
|
{ key: "phone", label: "전화번호", width: "w-[110px]" },
|
|
{ key: "memo", label: "메모", width: "w-[100px]" },
|
|
{ key: "is_default", label: "기본", width: "w-[50px]" },
|
|
]}
|
|
data={deliveryItems}
|
|
loading={deliveryLoading}
|
|
showRowNumber={false}
|
|
tableName={DELIVERY_TABLE}
|
|
emptyMessage="등록된 납품처가 없습니다"
|
|
/>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
|
|
{/* 거래처 등록/수정 모달 */}
|
|
<Dialog open={customerModalOpen} onOpenChange={setCustomerModalOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{customerEditMode ? "거래처 수정" : "거래처 등록"}</DialogTitle>
|
|
<DialogDescription>{customerEditMode ? "거래처 정보를 수정합니다." : "새로운 거래처를 등록합니다."}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-4 py-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="거래처 코드" className="h-9" disabled={customerEditMode} />
|
|
</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>
|
|
{renderSelect("division", customerForm.division, (v) => setCustomerForm((p) => ({ ...p, division: v })), "거래 유형")}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">상태 <span className="text-destructive">*</span></Label>
|
|
{renderSelect("status", customerForm.status, (v) => setCustomerForm((p) => ({ ...p, status: v })), "상태")}
|
|
</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>
|
|
<Select value={customerForm.internal_manager || "__none__"} onValueChange={(v) => setCustomerForm((p) => ({ ...p, internal_manager: v === "__none__" ? "" : v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="사내담당자 선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">선택 안 함</SelectItem>
|
|
{employeeOptions.map((emp) => (
|
|
<SelectItem key={emp.user_id} value={emp.user_id}>
|
|
{emp.user_name}{emp.position_name ? ` (${emp.position_name})` : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</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>
|
|
<DialogFooter>
|
|
<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" />} 저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 품목 추가 모달 */}
|
|
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
|
|
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
|
|
<DialogHeader>
|
|
<DialogTitle>품목 선택</DialogTitle>
|
|
<DialogDescription>거래처에 추가할 품목을 선택하세요.</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>
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
<TableRow>
|
|
<TableHead className="w-[40px] text-center">
|
|
<input type="checkbox"
|
|
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]">품목코드</TableHead>
|
|
<TableHead className="min-w-[140px]">품명</TableHead>
|
|
<TableHead className="w-[100px]">규격</TableHead>
|
|
<TableHead className="w-[100px]">재질</TableHead>
|
|
<TableHead className="w-[60px]">단위</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{itemSearchResults.length === 0 ? (
|
|
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</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"><input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly /></TableCell>
|
|
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
|
|
<TableCell className="text-sm">{item.item_name}</TableCell>
|
|
<TableCell className="text-xs">{item.size}</TableCell>
|
|
<TableCell className="text-xs">{item.material}</TableCell>
|
|
<TableCell className="text-xs">{item.unit}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<DialogFooter>
|
|
<div className="flex items-center gap-2 w-full justify-between">
|
|
<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>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 품목 상세정보 입력 모달 (2단계) */}
|
|
<FullscreenDialog
|
|
open={itemDetailOpen}
|
|
onOpenChange={setItemDetailOpen}
|
|
title={`📋 품목 상세정보 ${editItemData ? "수정" : "입력"} — ${selectedCustomer?.customer_name || ""}`}
|
|
description={editItemData ? "거래처 품번/품명과 기간별 단가를 수정합니다." : "선택한 품목의 거래처 품번/품명과 기간별 단가를 설정합니다."}
|
|
defaultMaxWidth="max-w-[1100px]"
|
|
footer={
|
|
<>
|
|
<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>
|
|
</>
|
|
}
|
|
>
|
|
|
|
<div className="space-y-6 py-2">
|
|
{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 || ""} | {item.unit || ""}</div>
|
|
</div>
|
|
|
|
<div className="flex gap-4 p-4">
|
|
{/* 좌: 거래처 품번/품명 (다중) */}
|
|
<div className="flex-1 border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/10">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-sm font-semibold">🏷 거래처 품번/품명 관리</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="space-y-2">
|
|
{mappingRows.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground py-2">입력된 거래처 품번이 없습니다</div>
|
|
) : (<>
|
|
<div className="flex gap-2 items-center text-[10px] text-muted-foreground font-medium">
|
|
<span className="w-4 shrink-0"></span>
|
|
<span className="flex-1">거래처 품번</span>
|
|
<span className="flex-1">거래처 품명</span>
|
|
<span className="w-7 shrink-0"></span>
|
|
</div>
|
|
{mappingRows.map((mRow, mIdx) => (
|
|
<div key={mRow._id} className="flex gap-2 items-center">
|
|
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
|
|
<Input value={mRow.customer_item_code}
|
|
onChange={(e) => updateMappingRow(itemKey, mRow._id, "customer_item_code", e.target.value)}
|
|
placeholder="거래처 품번" className="h-8 text-sm flex-1" />
|
|
<Input value={mRow.customer_item_name}
|
|
onChange={(e) => updateMappingRow(itemKey, mRow._id, "customer_item_name", e.target.value)}
|
|
placeholder="거래처 품명" className="h-8 text-sm 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>
|
|
</div>
|
|
))}
|
|
</>)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 우: 기간별 단가 */}
|
|
<div className="flex-1 border rounded-lg p-4 bg-amber-50/30 dark:bg-amber-950/10">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-sm font-semibold">💰 기간별 단가 설정</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">
|
|
{prices.map((price, pIdx) => (
|
|
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-muted-foreground">단가 {pIdx + 1}</span>
|
|
{prices.length > 1 && (
|
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
|
|
onClick={() => removePriceRow(itemKey, price._id)}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{/* 기간 */}
|
|
<div className="flex gap-2 items-center">
|
|
<div className="flex-1">
|
|
<FormDatePicker value={price.start_date}
|
|
onChange={(v) => updatePriceRow(itemKey, price._id, "start_date", v)} placeholder="시작일" />
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">~</span>
|
|
<div className="flex-1">
|
|
<FormDatePicker value={price.end_date}
|
|
onChange={(v) => updatePriceRow(itemKey, price._id, "end_date", v)} placeholder="종료일" />
|
|
</div>
|
|
<div className="w-[80px]">
|
|
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(itemKey, price._id, "currency_code", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><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="flex gap-2 items-center">
|
|
<div className="w-[90px]">
|
|
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(itemKey, price._id, "base_price_type", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Input value={price.base_price}
|
|
onChange={(e) => updatePriceRow(itemKey, price._id, "base_price", e.target.value)}
|
|
className="h-8 text-xs text-right flex-1" placeholder="기준가" />
|
|
<div className="w-[90px]">
|
|
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(itemKey, price._id, "discount_type", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><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>
|
|
</div>
|
|
<Input value={price.discount_value}
|
|
onChange={(e) => updatePriceRow(itemKey, price._id, "discount_value", e.target.value)}
|
|
className="h-8 text-xs text-right w-[60px]" placeholder="0" />
|
|
<div className="w-[90px]">
|
|
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(itemKey, price._id, "rounding_unit_value", v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
|
|
<SelectContent>
|
|
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
{/* 계산된 단가 표시 */}
|
|
<div className="flex items-center justify-end gap-2 pt-1 border-t">
|
|
<span className="text-xs text-muted-foreground">계산 단가:</span>
|
|
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
</FullscreenDialog>
|
|
|
|
{/* 납품처 등록 모달 */}
|
|
<Dialog open={deliveryModalOpen} onOpenChange={setDeliveryModalOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>납품처 등록</DialogTitle>
|
|
<DialogDescription>{selectedCustomer?.customer_name}의 납품처를 등록합니다.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-4 py-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">납품처코드</Label>
|
|
<Input value={deliveryForm.destination_code || ""} onChange={(e) => setDeliveryForm((p) => ({ ...p, destination_code: e.target.value }))}
|
|
placeholder="납품처코드" className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">납품처명 <span className="text-destructive">*</span></Label>
|
|
<Input value={deliveryForm.destination_name || ""} onChange={(e) => setDeliveryForm((p) => ({ ...p, destination_name: e.target.value }))}
|
|
placeholder="납품처명" className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-sm">주소</Label>
|
|
<Input value={deliveryForm.address || ""} onChange={(e) => setDeliveryForm((p) => ({ ...p, address: e.target.value }))}
|
|
placeholder="주소" className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">담당자</Label>
|
|
<Input value={deliveryForm.manager_name || ""} onChange={(e) => setDeliveryForm((p) => ({ ...p, manager_name: e.target.value }))}
|
|
placeholder="담당자" className="h-9" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">전화번호</Label>
|
|
<Input value={deliveryForm.phone || ""} onChange={(e) => {
|
|
const formatted = formatField("phone", e.target.value);
|
|
setDeliveryForm((p) => ({ ...p, phone: formatted }));
|
|
const err = validateField("phone", formatted);
|
|
setFormErrors((p) => { const n = { ...p }; if (err) n.delivery_phone = err; else delete n.delivery_phone; return n; });
|
|
}}
|
|
placeholder="010-0000-0000" className={cn("h-9", formErrors.delivery_phone && "border-destructive")} />
|
|
{formErrors.delivery_phone && <p className="text-xs text-destructive">{formErrors.delivery_phone}</p>}
|
|
</div>
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-sm">메모</Label>
|
|
<Input value={deliveryForm.memo || ""} onChange={(e) => setDeliveryForm((p) => ({ ...p, memo: e.target.value }))}
|
|
placeholder="메모" className="h-9" />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeliveryModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleDeliverySave}><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={tableSettingsOpen}
|
|
onOpenChange={setTableSettingsOpen}
|
|
tableName={CUSTOMER_TABLE}
|
|
settingsId="customer-mng"
|
|
onSave={applyTableSettings}
|
|
/>
|
|
|
|
{ConfirmDialogComponent}
|
|
</div>
|
|
);
|
|
}
|