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 ( +
+
+ +
+ {children} +
+ ); +} + 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}건
-
+
+ @@ -950,7 +1405,7 @@ export default function CustomerManagementPage() { {/* 거래처 테이블 */} c.status !== "비활성"))} rowKey={(row) => row.id} loading={customerLoading} emptyMessage="등록된 거래처가 없어요" @@ -958,7 +1413,8 @@ export default function CustomerManagementPage() { onSelect={(id) => setSelectedCustomerId(id)} onRowDoubleClick={(row) => { setSelectedCustomerId(row.id); openCustomerEdit(); }} showRowNumber - showPagination={false} + showPagination + defaultPageSize={20} draggableColumns={false} columnOrderKey="c16-customer" /> @@ -981,60 +1437,57 @@ export default function CustomerManagementPage() {
) : ( <> - {/* 디테일 헤더 */} -
- {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() { - {/* ── 모달: 거래처 등록/수정 ── */} - - - + {/* ── 모달: 거래처 등록/수정 (3탭) ── */} + { + setCustomerModalOpen(open); + if (!open) { + setModalContactFormOpen(false); + setModalDeliveryFormOpen(false); + setModalContactForm({}); + setModalDeliveryForm({}); + setModalContactEditId(null); + setModalDeliveryEditId(null); + fetchCustomers(); + if (customerForm.customer_code) { + const cid = selectedCustomerId; + setSelectedCustomerId(null); + setTimeout(() => setSelectedCustomerId(cid), 50); + } + } + }}> + + {customerEditMode ? "거래처 수정" : "거래처 등록"} {customerEditMode ? "거래처 정보를 수정합니다." : "새로운 거래처를 등록합니다."} -
-
- - setCustomerForm((p) => ({ ...p, customer_code: e.target.value }))} - placeholder="거래처 코드" - className="h-9" - disabled={customerEditMode} - /> + + setCustomerModalTab(v)} className="flex flex-col flex-1 overflow-hidden"> +
+ + + 기본정보 + + + 담당자 관리 + {modalContacts.length > 0 && ( + {modalContacts.length} + )} + + + 납품처 관리 + {modalDeliveries.length > 0 && ( + {modalDeliveries.length} + )} + +
-
- - setCustomerForm((p) => ({ ...p, customer_name: e.target.value }))} - placeholder="거래처명" - className="h-9" - /> -
-
- - -
-
- - -
-
- - setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="거래처담당자" - className="h-9" - /> -
-
- - -
-
- - handleFormChange("contact_phone", e.target.value)} - placeholder="010-0000-0000" - className={cn("h-9", formErrors.contact_phone && "border-destructive")} - /> - {formErrors.contact_phone &&

{formErrors.contact_phone}

} -
-
- - handleFormChange("email", e.target.value)} - placeholder="example@email.com" - className={cn("h-9", formErrors.email && "border-destructive")} - /> - {formErrors.email &&

{formErrors.email}

} -
-
- - handleFormChange("business_number", e.target.value)} - placeholder="000-00-00000" - className={cn("h-9", formErrors.business_number && "border-destructive")} - /> - {formErrors.business_number &&

{formErrors.business_number}

} -
-
- - setCustomerForm((p) => ({ ...p, address: e.target.value }))} - placeholder="주소" - className="h-9" - /> -
-
- - + + {/* 기본정보 탭 */} + +
+
+
+ + setCustomerForm((p) => ({ ...p, customer_code: e.target.value }))} + placeholder={customerEditMode ? "" : "자동 생성"} + className={cn("h-9 font-mono", !customerEditMode && customerForm.customer_code && "bg-muted")} + readOnly={!customerEditMode && !!customerForm.customer_code} + /> +
+
+ + setCustomerForm((p) => ({ ...p, customer_name: e.target.value }))} + placeholder="거래처명" + className="h-9" + /> +
+
+ + +
+
+ + +
+
+ + setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} + placeholder="거래처담당자" + className="h-9" + /> +
+
+ + +
+
+ + handleFormChange("contact_phone", e.target.value)} + placeholder="010-0000-0000" + className={cn("h-9", formErrors.contact_phone && "border-destructive")} + /> + {formErrors.contact_phone &&

{formErrors.contact_phone}

} +
+
+ + handleFormChange("email", e.target.value)} + placeholder="example@email.com" + className={cn("h-9", formErrors.email && "border-destructive")} + /> + {formErrors.email &&

{formErrors.email}

} +
+
+ + handleFormChange("business_number", e.target.value)} + placeholder="000-00-00000" + className={cn("h-9", formErrors.business_number && "border-destructive")} + /> + {formErrors.business_number &&

{formErrors.business_number}

} +
+
+ + setCustomerForm((p) => ({ ...p, address: e.target.value }))} + placeholder="주소" + className="h-9" + /> +
+
+ + {/* 세금유형 */} +
+
+ + +
+
+ {taxTypeRows.map((row, idx) => ( +
+ {idx + 1} + + { + const v = e.target.value.replace(/[^\d.]/g, ""); + setTaxTypeRows((prev) => prev.map((r) => r._id === row._id ? { ...r, rate: v } : r)); + }} + placeholder="세율 %" + className="h-9 text-[13px] w-[80px] text-right" + /> + % + +
+ ))} +
+
+
+
+ + {/* 담당자 관리 탭 */} + +
+ {/* 담당자 목록 */} +
+ + 담당자 {modalContacts.length}명 + + +
+ +
+ {modalContactLoading ? ( +
+ +
+ ) : modalContacts.length === 0 ? ( +
+ + 등록된 담당자가 없어요 +
+ ) : ( +
+ + + 담당자명 + 전화번호 + 이메일 + 부서 + 메인 + 메모 + 관리 + + + + {[...modalContacts].sort((a, b) => { + const aMain = a.is_main === "Y" || a.is_main === true ? 0 : 1; + const bMain = b.is_main === "Y" || b.is_main === true ? 0 : 1; + return aMain - bMain; + }).map((c) => ( + + {c.contact_name} + {c.contact_phone} + {c.contact_email} + {c.department} + + + + {c.memo} + +
+ + +
+
+
+ ))} +
+
+ )} +
+ + {/* 담당자 폼 (인라인) */} + {modalContactFormOpen && ( +
+
{modalContactEditId ? "담당자 수정" : "담당자 추가"}
+
+
+ + setModalContactForm((p) => ({ ...p, contact_name: e.target.value }))} + placeholder="담당자명" + className="h-8 text-sm" + /> +
+
+ + { + const formatted = formatField("phone", e.target.value); + setModalContactForm((p) => ({ ...p, contact_phone: formatted })); + }} + placeholder="010-0000-0000" + className="h-8 text-sm" + /> +
+
+ + setModalContactForm((p) => ({ ...p, contact_email: e.target.value }))} + placeholder="example@email.com" + className="h-8 text-sm" + /> +
+
+ + setModalContactForm((p) => ({ ...p, department: e.target.value }))} + placeholder="부서명" + className="h-8 text-sm" + /> +
+
+ + setModalContactForm((p) => ({ ...p, memo: e.target.value }))} + placeholder="메모" + className="h-8 text-sm" + /> +
+
+ +
+
+
+ + +
+
+ )} +
+ + + {/* ── 탭3: 납품처 관리 ── */} + +
+ {/* 납품처 목록 헤더 */} +
+ + 납품처 {modalDeliveries.length}개 + + +
+ +
+ {modalDeliveryLoading ? ( +
+ +
+ ) : modalDeliveries.length === 0 ? ( +
+ + 등록된 납품처가 없어요 +
+ ) : ( + + + + 납품처코드 + 납품처명 + 주소 + 담당자 + 전화번호 + 메모 + 메인 + 관리 + + + + {[...modalDeliveries].sort((a, b) => { + const aMain = a.is_default === "Y" || a.is_default === true ? 0 : 1; + const bMain = b.is_default === "Y" || b.is_default === true ? 0 : 1; + return aMain - bMain; + }).map((d) => ( + + {d.destination_code} + {d.destination_name} + {d.address} + {d.manager_name} + {d.phone} + {d.memo} + + + + +
+ + +
+
+
+ ))} +
+
+ )} +
+ + {/* 납품처 폼 (인라인) */} + {modalDeliveryFormOpen && ( +
+
{modalDeliveryEditId ? "납품처 수정" : "납품처 추가"}
+
+
+ + setModalDeliveryForm((p) => ({ ...p, destination_code: e.target.value }))} + placeholder="자동 생성" + className={cn("h-8 text-sm font-mono", modalDeliveryForm.destination_code && "bg-muted")} + readOnly={!!modalDeliveryForm.destination_code && !modalDeliveryEditId} + /> +
+
+ + setModalDeliveryForm((p) => ({ ...p, destination_name: e.target.value }))} + placeholder="납품처명" + className="h-8 text-sm" + /> +
+
+ + setModalDeliveryForm((p) => ({ ...p, manager_name: e.target.value }))} + placeholder="담당자" + className="h-8 text-sm" + /> +
+
+ + setModalDeliveryForm((p) => ({ ...p, address: e.target.value }))} + placeholder="주소" + className="h-8 text-sm" + /> +
+
+ + { + const formatted = formatField("phone", e.target.value); + setModalDeliveryForm((p) => ({ ...p, phone: formatted })); + const err = validateField("phone", formatted); + setModalDeliveryFormErrors((p) => { + const n = { ...p }; + if (err) n.phone = err; else delete n.phone; + return n; + }); + }} + placeholder="010-0000-0000" + className={cn("h-8 text-sm", modalDeliveryFormErrors.phone && "border-destructive")} + /> + {modalDeliveryFormErrors.phone &&

{modalDeliveryFormErrors.phone}

} +
+
+ + setModalDeliveryForm((p) => ({ ...p, memo: e.target.value }))} + placeholder="메모" + className="h-8 text-sm" + /> +
+
+ +
+
+
+ + +
+
+ )} +
+
+ +
+ {!customerEditMode && ( + + )} + - +
@@ -1355,7 +2344,7 @@ export default function CustomerManagementPage() {
- + - + 품목 상세정보 {editItemData ? "수정" : "입력"} — {selectedCustomer?.customer_name || ""} @@ -1431,7 +2420,7 @@ export default function CustomerManagementPage() { : "선택한 품목의 거래처 품번/품명과 기간별 단가를 설정합니다."} -
+
{selectedItemsForDetail.map((item, idx) => { const itemKey = item.item_number || item.id; const mappingRows = itemMappings[itemKey] || []; @@ -1445,10 +2434,10 @@ export default function CustomerManagementPage() {
{itemKey} | {item.size || ""} | {item.unit || ""}
-
+
{/* 좌: 거래처 품번/품명 */} -
-
+
+
거래처 품번/품명 관리 @@ -1456,48 +2445,48 @@ export default function CustomerManagementPage() { 품번 추가
-
+
{mappingRows.length === 0 ? (
입력된 거래처 품번이 없어요
) : ( - <> -
- - 거래처 품번 - 거래처 품명 - -
- {mappingRows.map((mRow, mIdx) => ( -
- {mIdx + 1} - updateMappingRow(itemKey, mRow._id, "customer_item_code", e.target.value)} - placeholder="거래처 품번" - className="h-8 text-sm flex-1" - /> - updateMappingRow(itemKey, mRow._id, "customer_item_name", e.target.value)} - placeholder="거래처 품명" - className="h-8 text-sm flex-1" - /> - -
- ))} - + handleMappingDragEnd(itemKey, e)} + > + r._id)} strategy={verticalListSortingStrategy}> + {mappingRows.map((mRow, mIdx) => ( + + {mIdx + 1} + updateMappingRow(itemKey, mRow._id, "customer_item_code", e.target.value)} + placeholder="거래처 품번" + className="h-9 text-[13px] flex-1" + /> + updateMappingRow(itemKey, mRow._id, "customer_item_name", e.target.value)} + placeholder="거래처 품명" + className="h-9 text-[13px] flex-1" + /> + + + ))} + + )}
{/* 우: 기간별 단가 */} -
-
+
+
기간별 단가 설정 @@ -1505,41 +2494,77 @@ export default function CustomerManagementPage() { 단가 추가
-
+
{prices.map((price, pIdx) => ( -
-
- 단가 {pIdx + 1} - {prices.length > 1 && ( - - )} +
+
setCollapsedPriceCards((prev) => { + const next = new Set(prev); + if (next.has(price._id)) next.delete(price._id); else next.add(price._id); + return next; + })} + > +
+ {collapsedPriceCards.has(price._id) + ? + : + } + 단가 {pIdx + 1} + {collapsedPriceCards.has(price._id) && price.calculated_price && ( + + {price.start_date || "—"} ~ {price.end_date || "—"} · {Number(price.calculated_price).toLocaleString()} {priceCategoryOptions["currency_code"]?.find((o) => o.code === price.currency_code)?.label || ""} + + )} +
+
+ {prices.length > 1 && ( + + )} +
+ {!collapsedPriceCards.has(price._id) &&
{/* 기간 + 통화 */}
- updatePriceRow(itemKey, price._id, "start_date", e.target.value)} - className="h-8 text-xs flex-1" - /> - ~ - updatePriceRow(itemKey, price._id, "end_date", e.target.value)} - className="h-8 text-xs flex-1" - /> +
+ + { + const v = e.target.value; + updatePriceRow(itemKey, price._id, "start_date", v); + if (price.end_date && v > price.end_date) { + updatePriceRow(itemKey, price._id, "end_date", v); + } + }} + max={price.end_date || undefined} + className="h-9 text-[13px] w-full" + /> +
+ ~ +
+ + updatePriceRow(itemKey, price._id, "end_date", e.target.value)} + min={price.start_date || undefined} + className="h-9 text-[13px] w-full" + /> +
+
updatePriceRow(itemKey, price._id, "base_price", e.target.value)} - className="h-8 text-xs text-right flex-1" + value={price.base_price ? Number(price.base_price).toLocaleString() : ""} + onChange={(e) => { + const raw = e.target.value.replace(/[^\d.-]/g, ""); + updatePriceRow(itemKey, price._id, "base_price", raw); + }} + className="h-9 text-[13px] text-right flex-1" placeholder="기준가" />
@@ -1574,7 +2602,7 @@ export default function CustomerManagementPage() { value={price.discount_type} onValueChange={(v) => updatePriceRow(itemKey, price._id, "discount_type", v)} > - + 할인없음 {(priceCategoryOptions["discount_type"] || []).map((o) => ( @@ -1584,9 +2612,12 @@ export default function CustomerManagementPage() {
updatePriceRow(itemKey, price._id, "discount_value", e.target.value)} - className="h-8 text-xs text-right w-[60px]" + value={price.discount_value ? Number(price.discount_value).toLocaleString() : ""} + onChange={(e) => { + const raw = e.target.value.replace(/[^\d.-]/g, ""); + updatePriceRow(itemKey, price._id, "discount_value", raw); + }} + className="h-9 text-[13px] text-right w-[60px]" placeholder="0" />
@@ -1594,7 +2625,7 @@ export default function CustomerManagementPage() { value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(itemKey, price._id, "rounding_unit_value", v)} > - + {(priceCategoryOptions["rounding_unit_value"] || []).map((o) => ( {o.label} @@ -1604,12 +2635,18 @@ export default function CustomerManagementPage() {
{/* 계산 단가 */} -
- 계산 단가: - +
+ 계산 단가: + {price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"} + {price.calculated_price && price.currency_code && ( + + {priceCategoryOptions["currency_code"]?.find((o) => o.code === price.currency_code)?.label || ""} + + )}
+
}
))}
@@ -1638,86 +2675,6 @@ export default function CustomerManagementPage() { - {/* ── 모달: 납품처 등록 ── */} - - - - 납품처 등록 - {selectedCustomer?.customer_name || ""}의 납품처를 등록합니다. - -
-
- - setDeliveryForm((p) => ({ ...p, destination_code: e.target.value }))} - placeholder="납품처코드" - className="h-9" - /> -
-
- - setDeliveryForm((p) => ({ ...p, destination_name: e.target.value }))} - placeholder="납품처명" - className="h-9" - /> -
-
- - setDeliveryForm((p) => ({ ...p, address: e.target.value }))} - placeholder="주소" - className="h-9" - /> -
-
- - setDeliveryForm((p) => ({ ...p, manager_name: e.target.value }))} - placeholder="담당자" - className="h-9" - /> -
-
- - { - 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 &&

{formErrors.delivery_phone}

} -
-
- - setDeliveryForm((p) => ({ ...p, memo: e.target.value }))} - placeholder="메모" - className="h-9" - /> -
-
- - - - -
-
- {/* 엑셀 업로드 (멀티테이블) */} {excelChainConfig && ( { }; const parseNumber = (val: string) => val.replace(/,/g, ""); -const GRID_COLUMNS_CONFIG = [ - { key: "order_no", label: "수주번호" }, +// 마스터 헤더 레이아웃 (수주번호 뒤, 디테일 11컬럼 위에 colSpan으로 맵핑) +// 순서: 거래처 | 단가방식 | 납품처 | 납품장소 | 수주일 | 담당자 → 합계 colSpan = 11 +const MASTER_BODY_LAYOUT = [ + { key: "partner_id", label: "거래처", colSpan: 2 }, + { key: "price_mode", label: "단가방식", colSpan: 1 }, + { key: "delivery_partner_id", label: "납품처", colSpan: 2 }, + { key: "delivery_address", label: "납품장소", colSpan: 2 }, + { key: "order_date", label: "수주일", colSpan: 2 }, + { key: "manager_id", label: "담당자", colSpan: 2 }, +]; + +// 디테일 헤더 컬럼 +const DETAIL_HEADER_COLS = [ { key: "part_code", label: "품번" }, { key: "part_name", label: "품명" }, { key: "spec", label: "규격" }, @@ -55,9 +66,103 @@ const GRID_COLUMNS_CONFIG = [ { key: "amount", label: "금액" }, { key: "currency_code", label: "통화" }, { key: "due_date", label: "납기일" }, +]; + +// 필터용 전체 키 +const GRID_COLUMNS_CONFIG = [ + { key: "order_no", label: "수주번호" }, + ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })), + ...DETAIL_HEADER_COLS, { key: "memo", label: "메모" }, ]; +// 총 컬럼 수: 체크박스(1) + 화살표(1) + 수주번호(1) + 디테일(11) + 메모(1) = 15 +const TOTAL_COLS = 15; + +// 헤더 필터 Popover +function HeaderFilterPopover({ + colKey, colLabel, uniqueValues, filterValues, onToggle, onClear, +}: { + colKey: string; + colLabel: string; + uniqueValues: string[]; + filterValues: Set; + onToggle: (colKey: string, value: string) => void; + onClear: (colKey: string) => void; +}) { + const [filterSearch, setFilterSearch] = useState(""); + const hasFilter = filterValues.size > 0; + const filteredValues = uniqueValues.filter( + (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()) + ); + + return ( + + + + + e.stopPropagation()}> +
+
+ 필터: {colLabel} + {hasFilter && ( + + )} +
+
+ + setFilterSearch(e.target.value)} + placeholder="검색..." + className="h-7 text-xs pl-7" + /> +
+
+ {filteredValues.slice(0, 100).map((val) => { + const isSelected = filterValues.has(val); + return ( +
onToggle(colKey, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {filteredValues.length > 100 && ( +
+ ...외 {filteredValues.length - 100}개 +
+ )} +
+
+
+
+ ); +} + export default function SalesOrderPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent } = useConfirmDialog(); @@ -75,7 +180,6 @@ export default function SalesOrderPage() { const [masterForm, setMasterForm] = useState>({}); const [detailRows, setDetailRows] = useState([]); const [allowPriceEdit, setAllowPriceEdit] = useState(true); - const [orderGroups, setOrderGroups] = useState>({}); const [expandedOrders, setExpandedOrders] = useState>(new Set()); const [closingOrders, setClosingOrders] = useState>(new Set()); @@ -110,6 +214,10 @@ export default function SalesOrderPage() { // 테이블 설정 const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); + // 헤더 필터 & 정렬 + const [headerFilters, setHeaderFilters] = useState>>({}); + const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + // 카테고리 로드 useEffect(() => { const loadCategories = async () => { @@ -257,16 +365,6 @@ export default function SalesOrderPage() { }; }); - // order_no 기준 그룹핑 - const grouped: Record = {}; - for (const row of data) { - const key = row.order_no || "_no_order"; - if (!grouped[key]) { - grouped[key] = { master: row._master || {}, details: [] }; - } - grouped[key].details.push(row); - } - setOrderGroups(grouped); setOrders(data); setTotalCount(res.data?.data?.total || data.length); } catch (err) { @@ -278,6 +376,160 @@ export default function SalesOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); + // 디테일 컬럼별 고유값 (디테일 서브헤더 필터용) + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + for (const col of DETAIL_HEADER_COLS) { + const values = new Set(); + orders.forEach((row) => { + const val = row[col.key]; + if (val !== null && val !== undefined && val !== "") values.add(String(val)); + }); + result[col.key] = Array.from(values).sort(); + } + return result; + }, [orders]); + + // 마스터 필드 키 목록 (필터 분류용) + const MASTER_KEYS = new Set(["order_no", ...MASTER_BODY_LAYOUT.map((c) => c.key), "memo"]); + + // 카테고리 코드→라벨 변환 (마스터 필터용) + const resolveMasterLabel = useCallback((key: string, code: string) => { + if (!code) return ""; + if (key === "partner_id" || key === "manager_id" || key === "price_mode") { + return categoryOptions[key]?.find((o) => o.code === code)?.label || code; + } + return code; + }, [categoryOptions]); + + // 필터 + 정렬 적용된 데이터 → 그룹핑 + const filteredOrderGroups = useMemo(() => { + // 1차: order_no 기준 그룹핑 (필터 전) + const allGroups: Record = {}; + for (const row of orders) { + const key = row.order_no || "_no_order"; + if (!allGroups[key]) { + allGroups[key] = { master: row._master || {}, details: [] }; + } + allGroups[key].details.push(row); + } + + // 마스터 필터 / 디테일 필터 분리 + const masterFilters: Record> = {}; + const detailFilters: Record> = {}; + for (const [colKey, values] of Object.entries(headerFilters)) { + if (values.size === 0) continue; + if (MASTER_KEYS.has(colKey)) masterFilters[colKey] = values; + else detailFilters[colKey] = values; + } + + // 2차: 마스터 필터 적용 (그룹 단위 필터링) + let entries = Object.entries(allGroups); + if (Object.keys(masterFilters).length > 0) { + entries = entries.filter(([, group]) => + Object.entries(masterFilters).every(([colKey, values]) => { + const raw = group.master?.[colKey] ?? ""; + const label = resolveMasterLabel(colKey, String(raw)); + return values.has(label) || values.has(String(raw)); + }) + ); + } + + // 3차: 디테일 필터 적용 (행 단위 필터링) + if (Object.keys(detailFilters).length > 0) { + entries = entries + .map(([orderNo, group]) => { + const filtered = group.details.filter((row) => + Object.entries(detailFilters).every(([colKey, values]) => { + const cellVal = row[colKey] != null ? String(row[colKey]) : ""; + return values.has(cellVal); + }) + ); + return [orderNo, { ...group, details: filtered }] as [string, typeof group]; + }) + .filter(([, group]) => group.details.length > 0); + } + + // 4차: 정렬 + if (sortState) { + const { key, direction } = sortState; + if (MASTER_KEYS.has(key)) { + // 마스터 필드 정렬 → 그룹 단위 + entries.sort(([, a], [, b]) => { + const av = a.master?.[key] ?? ""; + const bv = b.master?.[key] ?? ""; + const na = Number(av); const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na; + return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); + }); + } else { + // 디테일 필드 정렬 → 각 그룹 내 디테일 정렬 + entries.forEach(([, group]) => { + group.details.sort((a, b) => { + const av = a[key] ?? ""; + const bv = b[key] ?? ""; + const na = Number(av); const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na; + return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); + }); + }); + } + } + + return Object.fromEntries(entries); + }, [orders, headerFilters, sortState, resolveMasterLabel]); + + // 마스터 컬럼별 고유값 (마스터 헤더 필터용) + const masterUniqueValues = useMemo(() => { + const result: Record = {}; + // 필터 전 전체 마스터에서 고유값 추출 + const seenMasters = new Map(); + orders.forEach((row) => { + if (row.order_no && row._master && !seenMasters.has(row.order_no)) { + seenMasters.set(row.order_no, row._master); + } + }); + const masters = Array.from(seenMasters.values()); + for (const col of [{ key: "order_no", label: "수주번호" }, ...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })), { key: "memo", label: "메모" }]) { + const values = new Set(); + masters.forEach((m) => { + const val = m?.[col.key]; + if (val !== null && val !== undefined && val !== "") { + values.add(resolveMasterLabel(col.key, String(val))); + } + }); + result[col.key] = Array.from(values).sort(); + } + return result; + }, [orders, resolveMasterLabel]); + + // 헤더 필터 토글/초기화 + const toggleHeaderFilter = (colKey: string, value: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + const set = new Set(next[colKey] || []); + if (set.has(value)) set.delete(value); else set.add(value); + if (set.size === 0) delete next[colKey]; else next[colKey] = set; + return next; + }); + }; + + const clearHeaderFilter = (colKey: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + delete next[colKey]; + return next; + }); + }; + + const handleSort = (key: string) => { + setSortState((prev) => + prev?.key === key + ? prev.direction === "asc" ? { key, direction: "desc" } : null + : { key, direction: "asc" } + ); + }; + const getCategoryLabel = (col: string, code: string) => { if (!code) return ""; const found = categoryOptions[col]?.find((o) => o.code === code); @@ -341,40 +593,29 @@ export default function SalesOrderPage() { } }; - // 삭제 (다중 선택) + // 삭제 (마스터 단위) const handleDelete = async () => { if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; } const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; - const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, { + const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, { description: "삭제된 데이터는 복구할 수 없습니다.", variant: "destructive", confirmText: "삭제", }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { - data: checkedIds.map((id) => ({ id })), - }); for (const orderNo of orderNos) { - const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); - const rows = remaining.data?.data?.data || remaining.data?.data?.rows || []; - if (rows.length === 0) { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, - autoFilter: true, + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + if (masters.length > 0) { + await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { + data: masters.map((m: any) => ({ id: m.id })), }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - if (masters.length > 0) { - await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { - data: masters.map((m: any) => ({ id: m.id })), - }); - } } } toast.success("삭제되었습니다."); @@ -646,56 +887,120 @@ export default function SalesOrderPage() { {/* 데이터 테이블 (트리 구조) */}
-
+
- - - - - - - - - - - - - - + {/* 체크박스 */} + {/* 펼침 화살표 */} + {/* 수주번호 */} + {/* 품번 / 거래처 */} + {/* 품명 / 거래처(cont) */} + {/* 규격 / 단가방식 */} + {/* 단위 / 납품처 */} + {/* 수량 / 납품처(cont) */} + {/* 출하수량 / 납품장소 */} + {/* 잔량 / 납품장소(cont) */} + {/* 단가 / 수주일 */} + {/* 금액 / 수주일(cont) */} + {/* 통화 / 담당자 */} + {/* 납기일 / 담당자(cont) */} + {/* 메모 */} - + { + const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id)); + const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allFilteredIds); + }} + > 0 && checkedIds.length === orders.length} - onCheckedChange={(checked) => setCheckedIds(checked ? orders.map((o) => o.id) : [])} + checked={(() => { + const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id)); + return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); + })()} + onCheckedChange={() => {}} /> - 수주번호 - 품번 - 품명 - 규격 - 단위 - 수량 - 출하수량 - 잔량 - 단가 - 금액 - 납기일 - 메모 + {/* 수주번호 (별도 컬럼) */} + +
+
handleSort("order_no")}> + 수주번호 + {sortState?.key === "order_no" && ( + sortState.direction === "asc" + ? + : + )} +
+ {(masterUniqueValues["order_no"] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> + )} +
+
+ {/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */} + {MASTER_BODY_LAYOUT.map((col) => ( + +
+
handleSort(col.key)}> + {col.label} + {sortState?.key === col.key && ( + sortState.direction === "asc" + ? + : + )} +
+ {(masterUniqueValues[col.key] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> + )} +
+
+ ))} + {/* 메모 (마스터) */} + +
+
handleSort("memo")}> + 메모 + {sortState?.key === "memo" && ( + sortState.direction === "asc" + ? + : + )} +
+ {(masterUniqueValues["memo"] || []).length > 0 && ( + ()} + onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} + /> + )} +
+
{loading ? ( - + - ) : Object.keys(orderGroups).length === 0 ? ( + ) : Object.keys(filteredOrderGroups).length === 0 ? ( - +
등록된 수주가 없어요 @@ -703,18 +1008,15 @@ export default function SalesOrderPage() { ) : ( - Object.entries(orderGroups).map(([orderNo, group]) => { + Object.entries(filteredOrderGroups).map(([orderNo, group]) => { const isExpanded = expandedOrders.has(orderNo); const detailIds = group.details.map((d) => d.id); const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id)); const someDetailChecked = detailIds.some((id) => checkedIds.includes(id)); const master = group.master; - const totalQty = group.details.reduce((s, d) => s + (parseFloat(d.qty) || 0), 0); - const totalAmount = group.details.reduce((s, d) => s + (parseFloat(d.amount) || 0), 0); - return ( - {/* 마스터 행 */} + {/* 마스터 행 — 마스터 테이블 필드만 표시 */} { if (expandedOrders.has(orderNo)) { - // 접기 — 애니메이션 후 제거 setClosingOrders((prev) => new Set(prev).add(orderNo)); setTimeout(() => { setExpandedOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; }); @@ -735,16 +1036,20 @@ export default function SalesOrderPage() { }} onDoubleClick={() => openEditModal(orderNo)} > - e.stopPropagation()}> + { + e.stopPropagation(); + setCheckedIds((prev) => { + if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); + return [...new Set([...prev, ...detailIds])]; + }); + }} + > { - setCheckedIds((prev) => { - if (checked) return [...new Set([...prev, ...detailIds])]; - return prev.filter((id) => !detailIds.includes(id)); - }); - }} + onCheckedChange={() => {}} /> @@ -753,25 +1058,100 @@ export default function SalesOrderPage() { : } - {orderNo} - - + {/* 수주번호 */} + + {orderNo} + ({group.details.length}) + + {/* 거래처 (colSpan=2) */} + + {master.partner_id ? (categoryOptions["partner_id"]?.find((o) => o.code === master.partner_id)?.label || master.partner_id) : ""} - {master.order_date ? ` · ${master.order_date}` : ""} - ({group.details.length}건) - {totalQty ? totalQty.toLocaleString() : ""} - - - - {totalAmount ? totalAmount.toLocaleString() : ""} - {master.due_date || ""} + {/* 단가방식 (colSpan=1) */} + + + {master.price_mode ? (categoryOptions["price_mode"]?.find((o) => o.code === master.price_mode)?.label || master.price_mode) : ""} + + + {/* 납품처 (colSpan=2) */} + + {master.delivery_partner_id || ""} + + {/* 납품장소 (colSpan=2) */} + + {master.delivery_address || ""} + + {/* 수주일 (colSpan=2) */} + + {master.order_date || ""} + + {/* 담당자 (colSpan=2) */} + + + {master.manager_id ? (categoryOptions["manager_id"]?.find((o) => o.code === master.manager_id)?.label || master.manager_id) : ""} + + + {/* 메모 */} - {master.memo || ""} + {master.memo || ""} + {/* 디테일 서브 헤더 (펼쳤을 때만) */} + {isExpanded && ( + + + + {/* 수주번호 컬럼 빈 셀 */} + {DETAIL_HEADER_COLS.map((col) => { + const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key); + const isSorted = sortState?.key === col.key; + const uniqueVals = Array.from(new Set( + group.details.map((d) => d[col.key]).filter((v: any) => v != null && v !== "").map(String) + )).sort(); + const filterVals = headerFilters[col.key] || new Set(); + return ( + +
+
handleSort(col.key)} + > + {col.label} + {isSorted && ( + sortState!.direction === "asc" + ? + : + )} +
+ {uniqueVals.length > 0 && ( + + )} +
+
+ ); + })} + +
+ )} + {/* 디테일 행 (펼쳤을 때만) */} {isExpanded && group.details.map((row, detailIdx) => { const isClosing = closingOrders.has(orderNo); @@ -791,20 +1171,21 @@ export default function SalesOrderPage() { }} onDoubleClick={() => openEditModal(row.order_no)} > - e.stopPropagation()}> - { - setCheckedIds((prev) => - checked ? [...prev, row.id] : prev.filter((id) => id !== row.id) - ); - }} - /> + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + > + {}} />
- + {/* 수주번호 컬럼 빈 셀 */} {row.part_code} {row.part_name} {row.spec} @@ -814,6 +1195,7 @@ export default function SalesOrderPage() { {row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""} {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} {row.amount ? Number(row.amount).toLocaleString() : ""} + {row.currency_code || ""} {row.due_date || ""} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index ca48e706..84827d72 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1068,11 +1068,11 @@ body span.messenger-time { background-color: hsl(var(--primary)) !important; } -/* 짝수 행 stripe — 트리 행(master/detail)은 제외 */ -[data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):nth-child(even) { +/* 짝수 행 stripe — 트리 행(master/detail)과 선택 행은 제외 */ +[data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):not(.row-selected):nth-child(even) { background-color: hsl(var(--muted) / 0.35); } -.dark [data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):nth-child(even) { +.dark [data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):not(.row-selected):nth-child(even) { background-color: hsl(var(--muted) / 0.18); } diff --git a/frontend/components/common/EDataTable.tsx b/frontend/components/common/EDataTable.tsx index 8b3c523c..526d9117 100644 --- a/frontend/components/common/EDataTable.tsx +++ b/frontend/components/common/EDataTable.tsx @@ -139,7 +139,7 @@ function SortableHeaderCell({ style={style} className={cn( col.width, col.minWidth, - "text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative", + "text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative overflow-hidden", col.align === "right" && "text-right", col.align === "center" && "text-center", )} @@ -586,7 +586,7 @@ export function EDataTable = any>({
c.key)} strategy={horizontalListSortingStrategy}> - + {/* 체크박스 */} {showCheckbox && ( @@ -663,7 +663,7 @@ export function EDataTable = any>({ } const id = getRowId(row, rowKey); - const isSelected = selectedId === id; + const isSelected = selectedId != null && String(selectedId) === String(id); const isChecked = checkedIds.includes(id); const highlighted = isSelected || isChecked; @@ -671,9 +671,9 @@ export function EDataTable = any>({ { @@ -787,7 +787,33 @@ export function EDataTable = any>({ -
+
+ { + if (e.key === "Enter") { + const val = parseInt((e.target as HTMLInputElement).value, 10); + if (!isNaN(val) && val >= 1 && val <= totalPages) { + setCurrentPage(val); + (e.target as HTMLInputElement).value = ""; + (e.target as HTMLInputElement).blur(); + } + } + }} + onBlur={(e) => { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 1 && val <= totalPages) { + setCurrentPage(val); + } + e.target.value = ""; + }} + /> + / {totalPages} 페이지 +
)} diff --git a/frontend/components/ui/table.tsx b/frontend/components/ui/table.tsx index 5ff9c2ce..7c271a78 100644 --- a/frontend/components/ui/table.tsx +++ b/frontend/components/ui/table.tsx @@ -43,7 +43,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { return (
); diff --git a/scripts/dev/start-all-parallel-linux.sh b/scripts/dev/start-all-parallel-linux.sh new file mode 100755 index 00000000..eef3de83 --- /dev/null +++ b/scripts/dev/start-all-parallel-linux.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +# 스크립트 위치에서 프로젝트 루트로 이동 +cd "$(dirname "$0")/../.." || exit 1 + +# 시작 시간 기록 +START_TIME=$(date +%s) +START_TIME_FORMATTED=$(date '+%Y-%m-%d %H:%M:%S') + +echo "" +echo "============================================" +echo "WACE 솔루션 - 전체 서비스 시작 (병렬 최적화) - Linux" +echo "============================================" +echo "[시작 시간] $START_TIME_FORMATTED" +echo "" + +# Docker 확인 +echo "[1/5] Docker 상태 확인 중..." +if ! docker --version >/dev/null 2>&1; then + echo "[ERROR] Docker가 설치되지 않았거나 실행 중이 아닙니다!" + exit 1 +fi +echo "[OK] Docker 환경 확인 완료" +echo "" + +# docker compose vs docker-compose 자동 감지 +if docker compose version >/dev/null 2>&1; then + DC="docker compose" +else + DC="docker-compose" +fi + +BACKEND_COMPOSE="docker/dev/docker-compose.backend.linux.yml" +FRONTEND_COMPOSE="docker/dev/docker-compose.frontend.linux.yml" + +# 기존 컨테이너 정리 +echo "[2/5] 기존 컨테이너 정리 중..." +docker rm -f pms-backend-linux pms-frontend-linux 2>/dev/null || true +docker network rm pms-network 2>/dev/null || true +docker network create pms-network 2>/dev/null || true +echo "[OK] 컨테이너 정리 완료" +echo "" + +# 병렬 빌드 시작 +PARALLEL_START=$(date +%s) +echo "[3/5] 이미지 빌드 중... (백엔드 + 프론트엔드 병렬)" +echo "" + +# 백엔드 빌드 (백그라운드) +( + $DC -f "$BACKEND_COMPOSE" build 2>&1 +) > /tmp/pms-backend-build.log 2>&1 & +BACKEND_BUILD_PID=$! + +# 프론트엔드 빌드 (백그라운드) +( + $DC -f "$FRONTEND_COMPOSE" build 2>&1 +) > /tmp/pms-frontend-build.log 2>&1 & +FRONTEND_BUILD_PID=$! + +echo " 백엔드 빌드 진행 중... (PID: $BACKEND_BUILD_PID)" +echo " 프론트엔드 빌드 진행 중... (PID: $FRONTEND_BUILD_PID)" +echo "" + +# 빌드 완료 대기 +wait $BACKEND_BUILD_PID +BACKEND_BUILD_RESULT=$? +wait $FRONTEND_BUILD_PID +FRONTEND_BUILD_RESULT=$? + +# 빌드 결과 확인 +BUILD_FAILED=false +if [ $BACKEND_BUILD_RESULT -eq 0 ]; then + echo "[OK] 백엔드 빌드 완료" +else + echo "[ERROR] 백엔드 빌드 실패!" + cat /tmp/pms-backend-build.log + BUILD_FAILED=true +fi + +if [ $FRONTEND_BUILD_RESULT -eq 0 ]; then + echo "[OK] 프론트엔드 빌드 완료" +else + echo "[ERROR] 프론트엔드 빌드 실패!" + cat /tmp/pms-frontend-build.log + BUILD_FAILED=true +fi + +if [ "$BUILD_FAILED" = true ]; then + echo "빌드 실패로 중단합니다." + exit 1 +fi + +PARALLEL_END=$(date +%s) +PARALLEL_DURATION=$((PARALLEL_END - PARALLEL_START)) +echo "[INFO] 빌드 소요 시간: ${PARALLEL_DURATION}초" +echo "" + +# 서비스 시작 +SERVICE_START=$(date +%s) +echo "[4/5] 서비스 시작 중..." + +# 기존 서비스 정리 +$DC -f "$BACKEND_COMPOSE" down -v 2>/dev/null || true +$DC -f "$FRONTEND_COMPOSE" down -v 2>/dev/null || true + +# 백엔드 시작 +echo " 백엔드 서비스 시작..." +$DC -f "$BACKEND_COMPOSE" up -d +if [ $? -ne 0 ]; then + echo "[ERROR] 백엔드 시작 실패!" + exit 1 +fi + +# 프론트엔드 시작 +echo " 프론트엔드 서비스 시작..." +$DC -f "$FRONTEND_COMPOSE" up -d +if [ $? -ne 0 ]; then + echo "[ERROR] 프론트엔드 시작 실패!" + exit 1 +fi + +echo "[OK] 서비스 시작 완료" + +SERVICE_END=$(date +%s) +SERVICE_DURATION=$((SERVICE_END - SERVICE_START)) +echo "[INFO] 서비스 시작 소요 시간: ${SERVICE_DURATION}초" +echo "" + +# 안정화 대기 +echo "[5/5] 서비스 안정화 대기 중... (10초)" +sleep 10 +echo "" + +echo "============================================" +echo "[완료] 모든 서비스가 시작되었습니다!" +echo "============================================" +echo "" +echo "[DATABASE] PostgreSQL: http://211.115.91.141:11134" +echo "[BACKEND] Node.js API: http://localhost:8080/api" +echo "[FRONTEND] Next.js: http://localhost:9771" +echo "" +echo "[서비스 상태 확인]" +echo " $DC -f $BACKEND_COMPOSE ps" +echo " $DC -f $FRONTEND_COMPOSE ps" +echo "" +echo "[로그 확인]" +echo " 백엔드: $DC -f $BACKEND_COMPOSE logs -f" +echo " 프론트엔드: $DC -f $FRONTEND_COMPOSE logs -f" +echo "" +echo "[서비스 중지]" +echo " $DC -f $BACKEND_COMPOSE down" +echo " $DC -f $FRONTEND_COMPOSE down" +echo "" + +# 종료 시간 계산 +END_TIME=$(date +%s) +END_TIME_FORMATTED=$(date '+%Y-%m-%d %H:%M:%S') +DURATION=$((END_TIME - START_TIME)) +MINUTES=$((DURATION / 60)) +SECONDS=$((DURATION % 60)) + +echo "============================================" +echo "[종료 시간] $END_TIME_FORMATTED" +echo "[총 소요 시간] ${MINUTES}분 ${SECONDS}초" +echo " - 빌드: ${PARALLEL_DURATION}초" +echo " - 서비스 시작: ${SERVICE_DURATION}초" +echo "============================================"