diff --git a/docs/coding-rules/presets/erp-preset-type-b-master-detail.html b/docs/coding-rules/presets/erp-preset-type-b-master-detail.html
index 9ca91da2..74af9e66 100644
--- a/docs/coding-rules/presets/erp-preset-type-b-master-detail.html
+++ b/docs/coding-rules/presets/erp-preset-type-b-master-detail.html
@@ -95,21 +95,19 @@ html, body { height: 100%; overflow: hidden; font-size: 13px; }
═══════════════════════════════════════════ */
.main-content {
display: flex; flex: 1; overflow: hidden;
- background: hsl(var(--background));
}
/* Master Panel (Left) */
.panel-master {
display: flex; flex-direction: column;
min-width: 250px; overflow: hidden;
- background: hsl(var(--muted));
- border-right: none;
+ background: hsl(var(--background));
}
.panel-header {
display: flex; align-items: center; justify-content: space-between;
- padding: 12px 16px;
+ padding: 10px 16px; height: 44px; min-height: 44px;
border-bottom: 1px solid hsl(var(--border));
- background: hsl(var(--muted));
+ background: hsl(var(--card));
}
.panel-header-left { display: flex; align-items: center; gap: 10px; }
.panel-title { font-size: 13px; font-weight: 700; color: hsl(var(--foreground)); }
@@ -121,26 +119,19 @@ html, body { height: 100%; overflow: hidden; font-size: 13px; }
/* Resize Handle */
.resize-handle {
- width: 6px; min-width: 6px; cursor: col-resize;
- background: hsl(var(--border)); transition: background 0.15s;
- position: relative; z-index: 10;
+ width: 5px; min-width: 5px; cursor: col-resize;
+ background: hsl(var(--border) / 0.6); transition: all 0.15s;
+ position: relative; z-index: 10; flex-shrink: 0;
}
.resize-handle:hover, .resize-handle.active {
- background: hsl(var(--primary));
+ background: hsl(var(--primary) / 0.5);
}
-.resize-handle::after {
- content: ''; position: absolute; top: 50%; left: 50%;
- transform: translate(-50%, -50%);
- width: 2px; height: 30px; border-radius: 2px;
- background: hsl(var(--muted-foreground) / 0.5); opacity: 0; transition: opacity 0.15s;
-}
-.resize-handle:hover::after, .resize-handle.active::after { opacity: 1; }
/* Detail Panel (Right) */
.panel-detail {
display: flex; flex-direction: column;
min-width: 250px; flex: 1; overflow: hidden;
- background: hsl(var(--muted));
+ background: hsl(var(--background));
}
/* ═══════════════════════════════════════════
@@ -148,6 +139,7 @@ html, body { height: 100%; overflow: hidden; font-size: 13px; }
═══════════════════════════════════════════ */
.table-wrapper {
flex: 1; overflow: auto; position: relative;
+ background: hsl(var(--background));
}
table {
width: 100%; border-collapse: collapse; table-layout: fixed;
@@ -156,22 +148,22 @@ thead { position: sticky; top: 0; z-index: 5; }
thead th {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: hsl(var(--muted-foreground));
- padding: 10px 12px; text-align: left;
- background: hsl(var(--card)); border-bottom: 1px solid hsl(var(--border));
+ padding: 9px 12px; text-align: left;
+ background: hsl(var(--muted)); border-bottom: 1px solid hsl(var(--border));
white-space: nowrap; user-select: none;
}
tbody tr {
- border-bottom: 1px solid hsl(var(--border));
+ border-bottom: 1px solid hsl(var(--border) / 0.5);
cursor: pointer; transition: all 0.1s;
border-left: 3px solid transparent;
}
-tbody tr:hover { background: hsl(var(--accent)); }
+tbody tr:hover { background: hsl(var(--accent) / 0.5); }
tbody tr.selected {
- background: hsl(var(--primary) / 0.08);
+ background: hsl(var(--primary) / 0.06);
border-left: 3px solid hsl(var(--primary));
}
tbody td {
- padding: 9px 12px; color: hsl(var(--muted-foreground));
+ padding: 8px 12px; color: hsl(var(--muted-foreground));
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
tbody tr.selected td { color: hsl(var(--foreground)); }
@@ -201,8 +193,8 @@ tbody tr.selected .cell-mono { color: hsl(var(--primary)); }
.empty-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
flex: 1; padding: 40px;
- border: 2px dashed hsl(var(--border)); border-radius: var(--radius);
- margin: 20px; text-align: center;
+ border: 2px dashed hsl(var(--border) / 0.6); border-radius: var(--radius);
+ margin: 16px; text-align: center;
}
.empty-state-icon {
width: 48px; height: 48px; color: hsl(var(--muted-foreground) / 0.5); margin-bottom: 16px;
@@ -215,17 +207,18 @@ tbody tr.selected .cell-mono { color: hsl(var(--primary)); }
═══════════════════════════════════════════ */
.tabs {
display: flex; border-bottom: 1px solid hsl(var(--border));
- background: hsl(var(--muted)); padding: 0 16px;
+ background: hsl(var(--card)); padding: 0 16px;
+ min-height: 38px;
}
.tab {
display: flex; align-items: center; gap: 6px;
- padding: 10px 16px; font-size: 12px; font-weight: 600;
+ padding: 9px 16px; font-size: 12px; font-weight: 600;
color: hsl(var(--muted-foreground)); cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s; user-select: none;
white-space: nowrap;
}
-.tab:hover { color: hsl(var(--muted-foreground)); }
+.tab:hover { color: hsl(var(--foreground)); }
.tab.active {
color: hsl(var(--foreground)); border-bottom-color: hsl(var(--primary));
}
@@ -236,7 +229,9 @@ tbody tr.selected .cell-mono { color: hsl(var(--primary)); }
/* Detail Sub-Header */
.detail-sub-header {
display: flex; align-items: center; justify-content: space-between;
- padding: 10px 16px; border-bottom: 1px solid hsl(var(--border));
+ padding: 8px 16px; border-bottom: 1px solid hsl(var(--border));
+ background: hsl(var(--card));
+ min-height: 38px;
}
.detail-sub-title { font-size: 12px; font-weight: 600; color: hsl(var(--muted-foreground)); }
.detail-sub-actions { display: flex; gap: 6px; }
diff --git a/docs/customer-management-tables.md b/docs/customer-management-tables.md
new file mode 100644
index 00000000..68e1d7de
--- /dev/null
+++ b/docs/customer-management-tables.md
@@ -0,0 +1,205 @@
+# 거래처관리 테이블 구조
+
+## 개요
+
+거래처관리 화면(`COMPANY_16/sales/customer`)에서 사용하는 테이블 목록.
+모든 테이블은 FK 제약 없이 **값 기반 참조**로 연결됨.
+
+---
+
+## 1. customer_mng (거래처 마스터)
+
+> 거래처 기본 정보. 메인 테이블.
+
+| 컬럼명 | 타입 | NULL | 기본값 | 설명 |
+|---|---|---|---|---|
+| `id` | integer | NO | auto increment | PK |
+| `customer_code` | varchar | YES | | 거래처 코드 (채번: `CUST-XXX`) |
+| `customer_name` | varchar | YES | | 거래처명 |
+| `division` | varchar | YES | | 거래 유형 (카테고리) |
+| `contact_person` | varchar | YES | | 담당자명 (레거시, `customer_contact`로 대체) |
+| `contact_phone` | varchar | YES | | 전화번호 (레거시) |
+| `email` | varchar | YES | | 이메일 (레거시) |
+| `business_number` | varchar | YES | | 사업자번호 |
+| `address` | text | YES | | 주소 |
+| `status` | varchar | YES | | 상태 (카테고리: 활성/비활성) |
+| `delivery_location` | varchar | YES | | 납품장소 (레거시, `delivery_destination`으로 대체) |
+| `internal_manager` | varchar | YES | | 사내담당자 (user_info.user_id 참조) |
+| `company_code` | varchar | YES | | 회사 코드 |
+| `writer` | varchar | YES | | 작성자 |
+| `created_date` | timestamptz | YES | | 생성일 |
+| `updated_date` | timestamptz | YES | | 수정일 |
+
+**채번 규칙**: `rule-1773627245664-rw6ny43cf` (거래처코드, `customer_code` 컬럼)
+
+---
+
+## 2. customer_contact (거래처 담당자)
+
+> 거래처별 복수 담당자 관리. `customer_id`(customer_mng.id)로 연결.
+
+| 컬럼명 | 타입 | NULL | 기본값 | 설명 |
+|---|---|---|---|---|
+| `id` | varchar | NO | | PK (UUID) |
+| `customer_id` | varchar | YES | | customer_mng.id 참조 |
+| `contact_name` | varchar | YES | | 담당자명 |
+| `contact_phone` | varchar | YES | | 연락처 |
+| `contact_email` | varchar | YES | | 이메일 |
+| `department` | varchar | YES | | 부서 |
+| `is_main` | varchar | YES | `'N'` | 메인 담당자 여부 (`Y`/`N`, 복수 가능) |
+| `memo` | varchar | YES | | 메모 |
+| `company_code` | varchar | YES | | 회사 코드 |
+| `writer` | varchar | YES | | 작성자 |
+| `created_date` | timestamp | YES | `now()` | 생성일 |
+| `updated_date` | timestamp | YES | `now()` | 수정일 |
+
+**참조 방식**: `customer_id` = `customer_mng.id` (값 기반, FK 없음)
+**메인 목록 표시**: `is_main = 'Y'`인 담당자의 이름/전화/이메일이 거래처 목록에 표시됨
+
+---
+
+## 3. customer_tax_type (거래처 세금유형)
+
+> 거래처별 세금유형 다중 설정. `customer_id`(customer_mng.id)로 연결.
+
+| 컬럼명 | 타입 | NULL | 기본값 | 설명 |
+|---|---|---|---|---|
+| `id` | varchar | NO | | PK (UUID) |
+| `customer_id` | varchar | YES | | customer_mng.id 참조 |
+| `tax_type_id` | varchar | YES | | 세금유형 코드 |
+| `tax_type_name` | varchar | YES | | 세금유형명 (카테고리) |
+| `rate` | numeric | YES | `0` | 세율 (%) |
+| `company_code` | varchar | YES | | 회사 코드 |
+| `writer` | varchar | YES | | 작성자 |
+| `created_date` | timestamp | YES | `now()` | 생성일 |
+| `updated_date` | timestamp | YES | `now()` | 수정일 |
+
+**카테고리**: `customer_tax_type.tax_type_name` → 부가세(일반), 부가세(영세), 면세, 기타
+
+---
+
+## 4. delivery_destination (납품처)
+
+> 거래처별 납품처 관리. `customer_code`(customer_mng.customer_code)로 연결.
+
+| 컬럼명 | 타입 | NULL | 기본값 | 설명 |
+|---|---|---|---|---|
+| `id` | varchar | NO | | PK (UUID) |
+| `customer_code` | varchar | YES | | customer_mng.customer_code 참조 |
+| `destination_code` | varchar | YES | | 납품처 코드 (채번: `DEST-XXX`) |
+| `destination_name` | varchar | YES | | 납품처명 |
+| `address` | varchar | YES | | 주소 |
+| `manager_name` | varchar | YES | | 담당자명 |
+| `phone` | varchar | YES | | 전화번호 |
+| `memo` | varchar | YES | | 메모 |
+| `is_default` | varchar | YES | | 메인 납품처 여부 (`Y`/`N`, 복수 가능) |
+| `company_code` | varchar | YES | | 회사 코드 |
+| `writer` | varchar | YES | | 작성자 |
+| `created_date` | timestamp | YES | | 생성일 |
+| `updated_date` | timestamp | YES | | 수정일 |
+
+**채번 규칙**: `rule-1773627245668-7ad2ka353` (납품처코드, `destination_code` 컬럼)
+**참조 방식**: `customer_code` = `customer_mng.customer_code` (값 기반, FK 없음)
+
+---
+
+## 5. customer_item_mapping (거래처-품목 매핑)
+
+> 거래처별 품목 매핑 + 거래처 품번/품명 관리. `customer_id`(customer_mng.customer_code)로 연결.
+
+| 컬럼명 | 타입 | NULL | 기본값 | 설명 |
+|---|---|---|---|---|
+| `id` | varchar | NO | | PK (UUID) |
+| `customer_id` | varchar | YES | | customer_mng.customer_code 참조 |
+| `item_id` | varchar | YES | | item_info.item_number 참조 |
+| `customer_item_code` | varchar | YES | | 거래처 품번 |
+| `customer_item_name` | varchar | YES | | 거래처 품명 |
+| `currency_code` | varchar | YES | | 통화 (카테고리) |
+| `current_unit_price` | varchar | YES | | 현재 단가 |
+| `discount_type` | varchar | YES | | 할인유형 (카테고리) |
+| `discount_value` | numeric | YES | | 할인값 |
+| `base_price` | numeric | YES | | 기준가 |
+| `calculated_price` | numeric | YES | | 계산 단가 |
+| `rounding_type` | varchar | YES | | 반올림 유형 |
+| `rounding_unit_value` | varchar | YES | | 반올림 단위 (카테고리) |
+| `start_date` | date | YES | | 적용 시작일 |
+| `end_date` | date | YES | | 적용 종료일 |
+| `status` | varchar | YES | | 상태 |
+| `is_active` | varchar | YES | | 활성 여부 |
+| `company_code` | varchar | YES | | 회사 코드 |
+| `writer` | varchar | YES | | 작성자 |
+| `created_date` | timestamp | YES | | 생성일 |
+| `updated_date` | timestamp | YES | | 수정일 |
+
+---
+
+## 6. customer_item_prices (거래처 품목 단가)
+
+> 거래처별 품목 기간별 단가 관리. `customer_id` + `item_id`로 연결.
+
+| 컬럼명 | 타입 | NULL | 기본값 | 설명 |
+|---|---|---|---|---|
+| `id` | varchar | NO | | PK (UUID) |
+| `mapping_id` | varchar | YES | | customer_item_mapping.id 참조 |
+| `customer_id` | varchar | YES | | customer_mng.customer_code 참조 |
+| `item_id` | varchar | YES | | item_info.item_number 참조 |
+| `start_date` | date | YES | | 적용 시작일 |
+| `end_date` | date | YES | | 적용 종료일 |
+| `unit_price` | numeric | YES | | 최종 단가 |
+| `currency_code` | varchar | YES | | 통화 (카테고리) |
+| `base_price_type` | varchar | YES | | 기준유형 (카테고리) |
+| `base_price` | numeric | YES | | 기준가 |
+| `discount_type` | varchar | YES | | 할인유형 (카테고리) |
+| `discount_value` | numeric | YES | | 할인값 |
+| `rounding_type` | varchar | YES | | 반올림 유형 |
+| `rounding_unit_value` | varchar | YES | | 반올림 단위 (카테고리) |
+| `calculated_price` | numeric | YES | | 계산 단가 |
+| `supply_price` | numeric | YES | | 공급가 |
+| `vat_included_price` | numeric | YES | | 부가세 포함가 |
+| `remarks` | varchar | YES | | 비고 |
+| `company_code` | varchar | YES | | 회사 코드 |
+| `writer` | varchar | YES | | 작성자 |
+| `created_date` | timestamp | YES | | 생성일 |
+| `updated_date` | timestamp | YES | | 수정일 |
+
+---
+
+## 테이블 관계도
+
+```
+customer_mng (마스터)
+ ├── customer_contact (customer_id = customer_mng.id)
+ ├── customer_tax_type (customer_id = customer_mng.id)
+ ├── delivery_destination (customer_code = customer_mng.customer_code)
+ ├── customer_item_mapping (customer_id = customer_mng.customer_code)
+ │ └── customer_item_prices (mapping_id = customer_item_mapping.id)
+ └── customer_item_prices (customer_id = customer_mng.customer_code)
+```
+
+> **주의**: `customer_contact`, `customer_tax_type`은 `customer_mng.id`(정수)로 연결되고,
+> `delivery_destination`, `customer_item_mapping`, `customer_item_prices`는 `customer_mng.customer_code`(문자열)로 연결됨.
+
+---
+
+## 카테고리 설정
+
+| 테이블 | 컬럼 | 값 (COMPANY_16) |
+|---|---|---|
+| `customer_mng` | `division` | 국내사업부, 해외사업부, 온라인사업부 |
+| `customer_mng` | `status` | 활성, 비활성 |
+| `customer_tax_type` | `tax_type_name` | 부가세(일반), 부가세(영세), 면세, 기타 |
+| `customer_item_prices` | `base_price_type` | 품목기준, 최종기준 등 |
+| `customer_item_prices` | `currency_code` | KRW, USD 등 |
+| `customer_item_prices` | `discount_type` | 할인금액, 할인율 등 |
+| `customer_item_prices` | `rounding_unit_value` | 절삭, 반올림, 올림 등 |
+
+---
+
+## 채번 규칙
+
+| 대상 | rule_id | 패턴 |
+|---|---|---|
+| 거래처코드 | `rule-1773627245664-rw6ny43cf` | `CUST-XXX` |
+| 납품처코드 | `rule-1773627245668-7ad2ka353` | `DEST-XXX` |
+
+채번 방식: DB max값 + 로컬 리스트 max값 중 큰 값 + 1
diff --git a/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx
index 26a3fd94..1d1b25d1 100644
--- a/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx
+++ b/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx
@@ -24,8 +24,14 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
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,
+ 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";
@@ -35,6 +41,7 @@ import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelU
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";
@@ -44,20 +51,41 @@ 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: "contact_person", label: "대표자" },
- { key: "contact_phone", label: "연락처" },
- { key: "division", 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 (
+
+ );
+}
+
export default function CustomerManagementPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-customer", CUSTOMER_TABLE, CUSTOMER_GRID_COLUMNS);
+ const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState([]);
@@ -66,6 +94,8 @@ export default function CustomerManagementPage() {
const [customers, setCustomers] = useState([]);
const [rawCustomers, setRawCustomers] = useState([]);
const [customerLoading, setCustomerLoading] = useState(false);
+ const [showInactive, setShowInactive] = useState(false);
+ const [mainContactMap, setMainContactMap] = useState>({});
const [customerCount, setCustomerCount] = useState(0);
const [selectedCustomerId, setSelectedCustomerId] = useState(null);
@@ -73,11 +103,13 @@ export default function CustomerManagementPage() {
const [rightTab, setRightTab] = useState<"items" | "delivery">("items");
// 우측: 품목 단가
const [priceItems, setPriceItems] = useState([]);
+ const [priceGroups, setPriceGroups] = useState>({});
const [priceLoading, setPriceLoading] = useState(false);
const [priceCheckedIds, setPriceCheckedIds] = useState([]);
+ const [expandedItems, setExpandedItems] = useState>(new Set());
+ const [collapsedPriceCards, setCollapsedPriceCards] = useState>(new Set());
// 우측: 납품처
const [deliveryItems, setDeliveryItems] = useState([]);
- const [deliveryCheckedIds, setDeliveryCheckedIds] = useState([]);
const [deliveryLoading, setDeliveryLoading] = useState(false);
// 품목 편집 데이터 (더블클릭 시 상세 입력 모달 재활용)
@@ -110,9 +142,29 @@ export default function CustomerManagementPage() {
}>>>({});
const [priceCategoryOptions, setPriceCategoryOptions] = useState>({});
- // 납품처 모달
- const [deliveryModalOpen, setDeliveryModalOpen] = useState(false);
- const [deliveryForm, setDeliveryForm] = useState>({});
+ // 거래처 모달 탭
+ const [customerModalTab, setCustomerModalTab] = useState<"basic" | "contacts" | "delivery">("basic");
+ // 담당자 (customer_contact) - 모달 내
+ const [modalContacts, setModalContacts] = useState([]);
+ const [modalContactLoading, setModalContactLoading] = useState(false);
+ const [modalContactForm, setModalContactForm] = useState>({});
+ const [modalContactEditId, setModalContactEditId] = useState(null);
+ const [modalContactFormOpen, setModalContactFormOpen] = useState(false);
+ const [modalContactSaving, setModalContactSaving] = useState(false);
+ // 납품처 (delivery_destination) - 모달 내
+ const [modalDeliveries, setModalDeliveries] = useState([]);
+ const [modalDeliveryLoading, setModalDeliveryLoading] = useState(false);
+ const [modalDeliveryForm, setModalDeliveryForm] = useState>({});
+ const [modalDeliveryEditId, setModalDeliveryEditId] = useState(null);
+ const [modalDeliveryFormOpen, setModalDeliveryFormOpen] = useState(false);
+ const [modalDeliverySaving, setModalDeliverySaving] = useState(false);
+ const [modalDeliveryFormErrors, setModalDeliveryFormErrors] = useState>({});
+
+ 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);
@@ -152,6 +204,12 @@ export default function CustomerManagementPage() {
} 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 })
@@ -177,21 +235,35 @@ export default function CustomerManagementPage() {
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) => ({
- ...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)
- : "",
- }));
+ 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 || "",
+ internal_manager: r.internal_manager
+ ? (employeeOptions.find((e: any) => e.user_id === r.internal_manager)?.user_name || r.internal_manager)
+ : "",
+ };
+ });
+ // 거래처코드 숫자 기준 내림차순 정렬
+ 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) {
@@ -200,10 +272,30 @@ export default function CustomerManagementPage() {
} finally {
setCustomerLoading(false);
}
- }, [searchFilters, categoryOptions, employeeOptions]);
+ }, [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 = {};
+ 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);
// 선택된 거래처의 품목 단가 조회
@@ -256,24 +348,28 @@ export default function CustomerManagementPage() {
return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const today = new Date().toISOString().split("T")[0];
+
+ // 품목 기준 그룹핑 — master: 첫 매핑 + 현재 단가, details: 전체 단가 리스트
+ const grouped: Record = {};
+ const flatItems: any[] = [];
const seenItemIds = new Set();
- const sortedMappings = [...mappings].sort((a: any, b: any) => (a.item_id || "").localeCompare(b.item_id || ""));
-
- setPriceItems(sortedMappings.map((m: any) => {
+ for (const m of mappings) {
const itemKey = m.item_id || "";
- const itemInfo = itemMap[itemKey] || {};
- const isFirstOfGroup = !seenItemIds.has(itemKey);
- if (itemKey) seenItemIds.add(itemKey);
+ if (seenItemIds.has(itemKey)) continue; // 품목당 첫 매핑만 마스터
+ seenItemIds.add(itemKey);
- const itemPriceList = allPrices.filter((p: any) => p.item_id === 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] || {};
- return {
+ const masterRow = {
...m,
- item_number: isFirstOfGroup ? itemKey : "",
- item_name: isFirstOfGroup ? (itemInfo.item_name || "") : "",
+ 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 || ""),
@@ -281,7 +377,21 @@ export default function CustomerManagementPage() {
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 {
@@ -293,8 +403,7 @@ export default function CustomerManagementPage() {
// 납품처 조회
useEffect(() => {
- if (!selectedCustomer?.customer_code) { setDeliveryItems([]); setDeliveryCheckedIds([]); return; }
- setDeliveryCheckedIds([]);
+ if (!selectedCustomer?.customer_code) { setDeliveryItems([]); return; }
const fetchDelivery = async () => {
setDeliveryLoading(true);
try {
@@ -316,12 +425,161 @@ export default function CustomerManagementPage() {
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) {
+ // 수정 — 로컬 리스트에서 교체
+ setModalContacts((prev) => prev.map((c) =>
+ c._localId === modalContactEditId ? { ...c, ...modalContactForm } : 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) {
+ setModalDeliveries((prev) => prev.map((d) =>
+ (d._localId || d.id) === modalDeliveryEditId ? { ...d, ...modalDeliveryForm } : 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 = () => {
+ 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 = () => {
@@ -330,6 +588,29 @@ export default function CustomerManagementPage() {
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);
};
@@ -345,6 +626,81 @@ export default function CustomerManagementPage() {
});
};
+ // 세금유형/담당자/납품처를 한번에 저장하는 헬퍼
+ 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; }
@@ -356,22 +712,72 @@ export default function CustomerManagementPage() {
}
setSaving(true);
try {
- const { id, created_date, updated_date, writer, company_code, ...fields } = customerForm;
+ const { id, created_date, updated_date, writer, company_code, _numberingRuleId, ...fields } = customerForm;
const cleanFields: Record = {};
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("수정되었습니다.");
+ await saveSubTables(id, cleanFields.customer_code || customerForm.customer_code);
+ toast.success("저장되었습니다.");
} else {
+ // 신규 등록
await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields);
- toast.success("등록되었습니다.");
+ // 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("거래처가 등록되었습니다.");
}
- setCustomerModalOpen(false);
+
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 {
@@ -383,7 +789,6 @@ export default function CustomerManagementPage() {
const handleCustomerDelete = async () => {
if (!selectedCustomerId) return;
const ok = await confirm("거래처를 삭제하시겠습니까?", {
- description: "관련된 품목 매핑, 단가, 납품처 정보도 함께 삭제됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
@@ -454,6 +859,17 @@ export default function CustomerManagementPage() {
}));
};
+ 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,
@@ -488,14 +904,29 @@ export default function CustomerManagementPage() {
[itemKey]: (prev[itemKey] || []).map((r) => {
if (r._id !== rowId) return r;
const updated = { ...r, [field]: value };
- if (["base_price", "discount_type", "discount_value"].includes(field)) {
+ if (["base_price", "discount_type", "discount_value", "rounding_unit_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));
+ // 절삭/반올림 적용
+ const rv = updated.rounding_unit_value;
+ const roundOpts = priceCategoryOptions["rounding_unit_value"] || [];
+ const roundLabel = roundOpts.find((o) => o.code === rv)?.label || "";
+ if (roundLabel.includes("절삭") || roundLabel.includes("버림") || roundLabel.includes("floor")) {
+ calc = Math.floor(calc);
+ } else if (roundLabel.includes("올림") || roundLabel.includes("ceil")) {
+ calc = Math.ceil(calc);
+ } else if (roundLabel.includes("반올림") || roundLabel.includes("round")) {
+ calc = Math.round(calc);
+ } else if (rv) {
+ // 단위 값 기반 (예: 10원 단위 절삭)
+ const unit = Number(rv);
+ if (!isNaN(unit) && unit > 0) calc = Math.floor(calc / unit) * unit;
+ }
+ updated.calculated_price = String(Math.floor(calc));
}
return updated;
}),
@@ -587,39 +1018,77 @@ export default function CustomerManagementPage() {
const mappingRows = itemMappings[itemKey] || [];
if (isEditingExisting && editItemData?.id) {
- 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, discount_type: null, discount_value: null, calculated_price: null,
- },
- });
+ // 기존 매핑 조회
+ 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();
+ 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: "mapping_id", operator: "equals", value: editItemData.id },
+ { columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code },
+ { columnName: "item_id", operator: "equals", value: itemKey },
]}, 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 })),
- });
- }
+ 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
);
- for (const price of priceRows) {
- await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
- id: crypto.randomUUID(),
- mapping_id: editItemData.id,
- customer_id: selectedCustomer.customer_code,
- item_id: itemKey,
+ const usedPriceIds = new Set();
+ 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,
@@ -627,6 +1096,25 @@ export default function CustomerManagementPage() {
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 {
@@ -695,25 +1183,6 @@ export default function CustomerManagementPage() {
}
};
- // 납품처 저장
- const handleDeliverySave = async () => {
- if (!deliveryForm.destination_name || !selectedCustomer) return;
- try {
- await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/add`, {
- id: crypto.randomUUID(),
- ...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 handlePriceItemDelete = async () => {
if (priceCheckedIds.length === 0) return;
@@ -751,27 +1220,6 @@ export default function CustomerManagementPage() {
}
};
- // 납품처 삭제
- const handleDeliveryDelete = async () => {
- if (deliveryCheckedIds.length === 0) return;
- const ok = await confirm(`선택한 ${deliveryCheckedIds.length}개 납품처를 삭제하시겠습니까?`, {
- variant: "destructive", confirmText: "삭제",
- });
- if (!ok) return;
- try {
- await apiClient.delete(`/table-management/tables/${DELIVERY_TABLE}/delete`, {
- data: deliveryCheckedIds.map((id) => ({ id })),
- });
- toast.success(`${deliveryCheckedIds.length}개 납품처가 삭제되었습니다.`);
- setDeliveryCheckedIds([]);
- const cid = selectedCustomerId;
- setSelectedCustomerId(null);
- setTimeout(() => setSelectedCustomerId(cid), 50);
- } catch {
- toast.error("삭제에 실패했습니다.");
- }
- };
-
// 컬럼 가시성 헬퍼
const isColumnVisible = (key: string) => ts.isVisible(key);
@@ -781,12 +1229,10 @@ export default function CustomerManagementPage() {
// EDataTable 컬럼 정의 (거래처 목록)
const customerColumns: EDataTableColumn[] = [
...(isColumnVisible("customer_code") ? [{ key: "customer_code", label: "거래처코드", width: "w-[120px]" }] : []),
- ...(isColumnVisible("customer_name") ? [{ key: "customer_name", label: "거래처명", minWidth: "min-w-[160px]" }] : []),
- ...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "대표자", width: "w-[90px]" }] : []),
- ...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "연락처", width: "w-[120px]" }] : []),
+ ...(isColumnVisible("customer_name") ? [{ key: "customer_name", label: "거래처명", minWidth: "min-w-[140px]" }] : []),
...(isColumnVisible("division") ? [{
key: "division",
- label: "유형",
+ label: "거래유형",
width: "w-[80px]",
render: (val: any) =>
val ? (
@@ -795,6 +1241,11 @@ export default function CustomerManagementPage() {
) : null,
}] : []),
+ ...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "담당자", width: "w-[80px]" }] : []),
+ ...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "전화번호", width: "w-[120px]" }] : []),
+ ...(isColumnVisible("email") ? [{ key: "email", label: "이메일", width: "w-[160px]" }] : []),
+ ...(isColumnVisible("business_number") ? [{ key: "business_number", label: "사업자번호", width: "w-[120px]" }] : []),
+ ...(isColumnVisible("address") ? [{ key: "address", label: "주소", minWidth: "min-w-[150px]" }] : []),
...(isColumnVisible("status") ? [{
key: "status",
label: "상태",
@@ -802,7 +1253,7 @@ export default function CustomerManagementPage() {
render: (val: any) =>
val ? (
{val}
@@ -924,14 +1375,18 @@ export default function CustomerManagementPage() {
{/* 패널 헤더 */}
-
+
거래처 목록
{customerCount}건
-
+
) : (
<>
- {/* 디테일 헤더 */}
-
- {selectedCustomer?.customer_name || "-"}
-
- {selectedCustomer?.customer_code || "-"}
-
-
-
- {/* 탭 */}
+ {/* 탭 + 버튼 통합 헤더 */}
setRightTab(v as "items" | "delivery")}
className="flex flex-col flex-1 overflow-hidden gap-0"
>
-
-
- 거래처별 품목정보
-
-
- 납품처 정보
- {deliveryItems.length > 0 && (
- {deliveryItems.length}
+
+
+
+ 거래처별 품목정보
+ {Object.keys(priceGroups).length > 0 && (
+ {Object.keys(priceGroups).length}
+ )}
+
+
+ 납품처 정보
+ {deliveryItems.length > 0 && (
+ {deliveryItems.length}
+ )}
+
+
+
+ {rightTab === "items" ? (
+ <>
+
+
+ >
+ ) : (
+
)}
-
-
+
+
{/* 품목정보 탭 */}
-
-
- 등록 품목 {priceItems.length}건
-
-
-
-
-
-
-
-
+
+
-
+
- ) : priceItems.length === 0 ? (
+ ) : Object.keys(priceGroups).length === 0 ? (
등록된 품목이 없어요
- ) : priceItems.map((p) => (
- openEditItem(p)}
- >
-
- {
- if (e.target.checked) setPriceCheckedIds((prev) => [...prev, p.id]);
- else setPriceCheckedIds((prev) => prev.filter((id) => id !== p.id));
+ ) : Object.entries(priceGroups).map(([itemKey, group]) => {
+ const isExpanded = expandedItems.has(itemKey);
+ const m = group.master;
+ const isChecked = priceCheckedIds.includes(m.id);
+ return (
+
+ {/* 마스터 행 */}
+ {
+ setExpandedItems((prev) => {
+ const next = new Set(prev);
+ if (next.has(itemKey)) next.delete(itemKey); else next.add(itemKey);
+ return next;
+ });
}}
- onClick={(e) => e.stopPropagation()}
- />
-
- {p.item_number}
- {p.item_name}
- {p.customer_item_code}
- {p.customer_item_name}
- {p.base_price_type}
-
- {p.base_price ? Number(p.base_price).toLocaleString() : ""}
-
- {p.discount_type}
- {p.discount_value}
-
- {p.calculated_price ? Number(p.calculated_price).toLocaleString() : ""}
-
- {p.currency_code}
-
- ))}
+ onDoubleClick={() => openEditItem(m)}
+ >
+ {
+ e.stopPropagation();
+ setPriceCheckedIds((prev) =>
+ prev.includes(m.id) ? prev.filter((id) => id !== m.id) : [...prev, m.id]
+ );
+ }}
+ >
+
+
+
+
+ {isExpanded
+ ?
+ :
+ }
+ {m.item_number}
+
+
+ {m.item_name}
+ {m.customer_item_code}
+ {m.customer_item_name}
+ {m.base_price_type}
+
+ {m.base_price ? Number(m.base_price).toLocaleString() : ""}
+
+ {m.discount_type}
+ {m.discount_value ? Number(m.discount_value).toLocaleString() : ""}
+
+ {m.calculated_price ? Number(m.calculated_price).toLocaleString() : ""}
+
+ {m.currency_code}
+
+
+ {/* 현재 단가 카드 (펼쳤을 때) */}
+ {isExpanded && (() => {
+ const cp = group.details.find((p) => p.is_current) || group.details[0];
+ if (!cp) return (
+
+ 등록된 단가가 없어요
+
+ );
+ return (
+
+
+
+ {/* 카드 헤더 */}
+
+
+
+ 적용 단가
+ 현재
+
+ {group.details.length > 1 && (
+
전체 {group.details.length}건 중
+ )}
+
+ {/* 카드 내용 */}
+
+
+ 기간
+
+ {cp.start_date ? String(cp.start_date).split("T")[0] : "—"} ~ {cp.end_date ? String(cp.end_date).split("T")[0] : "—"}
+
+
+
+ 기준유형
+ {cp.base_price_type_label || "-"}
+
+
+ 기준가
+ {cp.base_price ? Number(cp.base_price).toLocaleString() : "-"}
+
+
+ 할인유형
+ {cp.discount_type_label && cp.discount_type_label !== "할인없음" ? cp.discount_type_label : "-"}
+
+
+ 할인값
+ {cp.discount_value ? Number(cp.discount_value).toLocaleString() : "-"}
+
+
+ 단수처리
+
+ {cp.rounding_unit_value
+ ? (priceCategoryOptions["rounding_unit_value"]?.find((o) => o.code === cp.rounding_unit_value)?.label || cp.rounding_unit_value)
+ : "-"}
+
+
+
→
+
+ 계산단가
+
+ {(cp.calculated_price || cp.unit_price) ? Number(cp.calculated_price || cp.unit_price).toLocaleString() : "-"}
+ {cp.currency_label}
+
+
+
+
+
+
+ );
+ })()}
+
+ );
+ })}
@@ -1109,70 +1656,34 @@ export default function CustomerManagementPage() {
{/* 납품처 탭 */}
-
-
- 등록 납품처 {deliveryItems.length}건
-
-
-
-
-
-
-
-
+
+
-
-
- 0 && deliveryCheckedIds.length === deliveryItems.length}
- onChange={(e) => setDeliveryCheckedIds(e.target.checked ? deliveryItems.map((d) => d.id) : [])}
- />
-
+
납품처코드
납품처명
주소
담당자
전화번호
메모
- 기본
+ 메인
{deliveryLoading ? (
-
+
) : deliveryItems.length === 0 ? (
-
+
등록된 납품처가 없어요
) : deliveryItems.map((d) => (
-
-
- {
- if (e.target.checked) setDeliveryCheckedIds((prev) => [...prev, d.id]);
- else setDeliveryCheckedIds((prev) => prev.filter((id) => id !== d.id));
- }}
- onClick={(e) => e.stopPropagation()}
- />
-
+
{d.destination_code}
{d.destination_name}
{d.address}
@@ -1181,7 +1692,7 @@ export default function CustomerManagementPage() {
{d.memo}
{d.is_default && (
- 기본
+ 메인
)}
@@ -1198,138 +1709,616 @@ export default function CustomerManagementPage() {
- {/* ── 모달: 거래처 등록/수정 ── */}
-