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/design/change-management/page.tsx b/frontend/app/(main)/COMPANY_16/design/change-management/page.tsx index 00fedd3f..39f06769 100644 --- a/frontend/app/(main)/COMPANY_16/design/change-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/design/change-management/page.tsx @@ -57,6 +57,7 @@ import { import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // --- Types --- type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응"; @@ -866,185 +867,52 @@ export default function DesignChangeManagementPage() {
{currentTab === "ecr" ? ( - - - - No - {tsEcr.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {filteredEcr.length === 0 ? ( - - -
- - 조건에 맞는 ECR이 없어요 -
-
-
- ) : ( - filteredEcr.map((item, idx) => ( - handleRowClick(item.id)} - > - {idx + 1} - {tsEcr.isVisible("request_no") && {item.id}} - {tsEcr.isVisible("change_type") && ( - - - {item.changeType} - - - )} - {tsEcr.isVisible("status") && ( - - - {item.status} - - - )} - {tsEcr.isVisible("urgency") && ( - - {item.urgency === "긴급" ? ( - - 긴급 - - ) : ( - "-" - )} - - )} - {tsEcr.isVisible("target_name") && {item.target}} - {tsEcr.isVisible("drawing_no") && {item.drawingNo}} - {tsEcr.isVisible("req_dept") && {item.reqDept}} - {tsEcr.isVisible("requester") && {item.requester}} - {tsEcr.isVisible("request_date") && {item.date}} - {tsEcr.isVisible("ecn_no") && ( - - {item.ecnNo ? ( - - ) : ( - "-" - )} - - )} - - )) - )} -
-
+ {val} }, + { key: "changeType", label: "변경유형", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "urgency", label: "긴급", width: "w-[60px]", align: "center" as const, render: (val: any) => val === "긴급" ? 긴급 : - }, + { key: "target", label: "대상 품목/설비", width: "w-[200px]" }, + { key: "drawingNo", label: "도면번호", width: "w-[150px]" }, + { key: "reqDept", label: "요청부서", width: "w-[80px]" }, + { key: "requester", label: "요청자", width: "w-[70px]" }, + { key: "date", label: "요청일자", width: "w-[100px]" }, + { key: "ecnNo", label: "관련 ECN", width: "w-[130px]", render: (val: any) => val ? : - }, + ] as EDataTableColumn[]} + data={tsEcr.groupData(filteredEcr)} + rowKey={(row) => row.id} + selectedId={selectedId} + onSelect={(id) => { if (id) handleRowClick(id); }} + onRowClick={(row) => handleRowClick(row.id)} + emptyMessage="조건에 맞는 ECR이 없어요" + showRowNumber + showPagination={false} + draggableColumns={false} + /> ) : ( - - - - No - {tsEcn.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {filteredEcn.length === 0 ? ( - - -
- - 조건에 맞는 ECN이 없어요 -
-
-
- ) : ( - filteredEcn.map((item, idx) => ( - handleRowClick(item.id)} - > - {idx + 1} - {tsEcn.isVisible("ecn_no") && {item.id}} - {tsEcn.isVisible("status") && ( - - - {item.status} - - - )} - {tsEcn.isVisible("target") && {item.target}} - {tsEcn.isVisible("drawing_after") && {item.drawingAfter}} - {tsEcn.isVisible("designer") && {item.designer}} - {tsEcn.isVisible("ecn_date") && {item.date}} - {tsEcn.isVisible("apply_date") && {item.applyDate}} - {tsEcn.isVisible("notify_depts") && {item.notifyDepts.join(", ")}} - {tsEcn.isVisible("ecr_id") && ( - - - - )} - - )) - )} -
-
+ {val} }, + { key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => {val} }, + { key: "target", label: "대상 품목/설비", width: "w-[200px]" }, + { key: "drawingAfter", label: "도면 (변경 후)", width: "w-[160px]", render: (val: any) => {val} }, + { key: "designer", label: "설계담당", width: "w-[80px]" }, + { key: "date", label: "발행일자", width: "w-[100px]" }, + { key: "applyDate", label: "적용일자", width: "w-[100px]" }, + { key: "notifyDepts", label: "통보 부서", width: "w-[140px]", render: (val: any) => {Array.isArray(val) ? val.join(", ") : val} }, + { key: "ecrNo", label: "관련 ECR", width: "w-[130px]", render: (val: any) => }, + ] as EDataTableColumn[]} + data={tsEcn.groupData(filteredEcn)} + rowKey={(row) => row.id} + selectedId={selectedId} + onSelect={(id) => { if (id) handleRowClick(id); }} + onRowClick={(row) => handleRowClick(row.id)} + emptyMessage="조건에 맞는 ECN이 없어요" + showRowNumber + showPagination={false} + draggableColumns={false} + /> )}
diff --git a/frontend/app/(main)/COMPANY_16/design/design-request/page.tsx b/frontend/app/(main)/COMPANY_16/design/design-request/page.tsx index 3e2965d7..e83c22b3 100644 --- a/frontend/app/(main)/COMPANY_16/design/design-request/page.tsx +++ b/frontend/app/(main)/COMPANY_16/design/design-request/page.tsx @@ -44,6 +44,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { getDesignRequestList, createDesignRequest, @@ -460,95 +461,42 @@ export default function DesignRequestPage() { {/* 테이블 영역 */}
-
- {loading ? ( -
- - 불러오는 중... -
- ) : ( - - - - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {filteredRequests.length === 0 && ( - - -
- - 등록된 설계의뢰가 없어요 -
-
-
- )} - {filteredRequests.map((item) => { - const progress = getProgress(item.status); + + columns={ts.visibleColumns.map((col): EDataTableColumn => ({ + key: col.key, + label: col.label, + width: col.key === "request_no" ? "w-[100px]" : col.key === "design_type" ? "w-[70px]" : col.key === "status" ? "w-[70px]" : col.key === "priority" ? "w-[60px]" : col.key === "customer" ? "w-[90px]" : col.key === "designer" ? "w-[70px]" : col.key === "due_date" ? "w-[85px]" : col.key === "progress" ? "w-[65px]" : undefined, + align: (col.key === "design_type" || col.key === "status" || col.key === "priority" || col.key === "progress") ? "center" : undefined, + render: col.key === "request_no" + ? (val: any) => {val || "-"} + : col.key === "design_type" + ? (val: any) => val ? {val} : - + : col.key === "status" + ? (val: any) => {val} + : col.key === "priority" + ? (val: any) => {val} + : col.key === "progress" + ? (_val: any, row: DesignRequest) => { + const progress = STATUS_PROGRESS[row.status] ?? 0; return ( - handleRowClick(item.id)} - > - {ts.isVisible("request_no") && {item.request_no || "-"}} - {ts.isVisible("design_type") && ( - - {item.design_type ? ( - {item.design_type} - ) : "-"} - - )} - {ts.isVisible("status") && ( - - {item.status} - - )} - {ts.isVisible("priority") && ( - - {item.priority} - - )} - {ts.isVisible("target_name") && {item.target_name || "-"}} - {ts.isVisible("customer") && {item.customer || "-"}} - {ts.isVisible("designer") && {item.designer || "-"}} - {ts.isVisible("due_date") && {item.due_date || "-"}} - {ts.isVisible("progress") && ( - -
-
-
-
- {progress}% -
- - )} - +
+
+
+
+ {progress}% +
); - })} - -
- )} -
+ } + : undefined, + }))} + data={ts.groupData(filteredRequests)} + loading={loading} + emptyMessage="등록된 설계의뢰가 없어요" + selectedId={selectedId} + onSelect={(id) => setSelectedId(id)} + onRowClick={(row) => handleRowClick(row.id)} + draggableColumns={false} + />
{/* 상세 정보 다이얼로그 */} diff --git a/frontend/app/(main)/COMPANY_16/design/my-work/page.tsx b/frontend/app/(main)/COMPANY_16/design/my-work/page.tsx index e0092b21..8c136cb9 100644 --- a/frontend/app/(main)/COMPANY_16/design/my-work/page.tsx +++ b/frontend/app/(main)/COMPANY_16/design/my-work/page.tsx @@ -76,6 +76,7 @@ import { import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { useAuth } from "@/hooks/useAuth"; import { getMyWork, @@ -1244,66 +1245,50 @@ export default function MyWorkPage() { )} {viewMode === "list" && ( - - - - 프로젝트 - 업무명 - 유형 - 상태 - 종료일 - 진행률 - - - - {filteredTasks - .sort((a, b) => { - const ad = a.status !== "완료" && new Date(a.end) < today; - const bd = b.status !== "완료" && new Date(b.end) < today; - if (ad && !bd) return -1; - if (!ad && bd) return 1; - const ord: Record = { 진행중: 0, 대기: 1, 검토중: 2, 완료: 3 }; - return (ord[a.status] ?? 9) - (ord[b.status] ?? 9); - }) - .map((t) => { - const isDelay = t.status !== "완료" && new Date(t.end) < today; - const displayStatus = isDelay ? "지연" : t.status; - const isSelected = selectedTaskKey === `${t.projectId}||${t.name}`; - return ( - handleSelectTask(t.projectId, t.name)} - > - - {t.projectId} -
- {t.projectName} -
- {t.name} - {t.category} - - {displayStatus} - - {t.end} - -
-
-
-
- {t.progress}% -
- - - ); - })} - {filteredTasks.length === 0 && ( - - 검색 결과가 없어요 - - )} - -
+ ( +
+ {row.projectId} +
+ {row.projectName} +
+ )}, + { key: "name", label: "업무명" }, + { key: "category", label: "유형", width: "w-[65px]" }, + { key: "status", label: "상태", width: "w-[55px]", align: "center", render: (_v, row) => { + const isDelay = row.status !== "완료" && new Date(row.end) < today; + const displayStatus = isDelay ? "지연" : row.status; + return {displayStatus}; + }}, + { key: "end", label: "종료일", width: "w-[80px]", render: (v, row) => { + const isDelay = row.status !== "완료" && new Date(row.end) < today; + return {v}; + }}, + { key: "progress", label: "진행률", width: "w-[70px]", sortable: true, render: (v) => ( +
+
+
+
+ {v}% +
+ )}, + ] as EDataTableColumn[]} + data={[...filteredTasks].sort((a, b) => { + const ad = a.status !== "완료" && new Date(a.end) < today; + const bd = b.status !== "완료" && new Date(b.end) < today; + if (ad && !bd) return -1; + if (!ad && bd) return 1; + const ord: Record = { 진행중: 0, 대기: 1, 검토중: 2, 완료: 3 }; + return (ord[a.status] ?? 9) - (ord[b.status] ?? 9); + })} + rowKey={(row) => `${row.projectId}||${row.name}`} + selectedId={selectedTaskKey} + onRowClick={(row) => handleSelectTask(row.projectId, row.name)} + emptyMessage="검색 결과가 없어요" + showPagination={false} + draggableColumns={false} + /> )} {viewMode === "timesheet" && ( diff --git a/frontend/app/(main)/COMPANY_16/design/project/page.tsx b/frontend/app/(main)/COMPANY_16/design/project/page.tsx index 1aaafc9f..7e66b696 100644 --- a/frontend/app/(main)/COMPANY_16/design/project/page.tsx +++ b/frontend/app/(main)/COMPANY_16/design/project/page.tsx @@ -63,6 +63,7 @@ import { import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // --- Types --- type ProjectStatus = "진행중" | "계획" | "보류" | "완료"; @@ -728,133 +729,63 @@ export default function DesignProjectPage() {
- - - - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {loading ? ( - - - -
로딩 중...
-
-
- ) : treeRows.length === 0 ? ( - - -
- - 조건에 맞는 프로젝트가 없어요 -
-
-
- ) : ( - treeRows.map(({ project: p, depth }) => { - const hasChildren = filteredProjects.some((c) => c.parentId === p.id); - const isExpanded = expandedIds[p.id] !== false; - const childCount = getAllDescendants(projects, p.id).length; - - return ( - = 2 && "bg-muted/15" - )} - onClick={() => { - setSelectedId(p.id); - setDetailTab("wbs"); - fetchTaskDetails(p.id); - }} - > - {ts.isVisible("project_no") && ( - -
- {hasChildren ? ( - - ) : ( - - )} - - {p.projectNo} - - {p.relation && ( - - {getRelationLabel(p.relation)} - - )} -
-
- )} - {ts.isVisible("status") && ( - - - {p.status} - - - )} - {ts.isVisible("name") && ( - - {p.name} - {childCount > 0 && ( - - {childCount} - - )} - - )} - {ts.isVisible("pm") && {p.pm}} - {ts.isVisible("customer") && {p.customer}} - {ts.isVisible("start_date") && {p.startDate}} - {ts.isVisible("end_date") && {p.endDate}} - {ts.isVisible("progress") && ( - -
-
-
-
- {p.progress}% -
- - )} - {ts.isVisible("source_no") && {p.sourceNo || "-"}} - - ); - }) - )} - -
+ { + const depth = row._depth ?? 0; + const hasChildren = filteredProjects.some((c) => c.parentId === row.id); + const isExpanded = expandedIds[row.id] !== false; + return ( +
+ {hasChildren ? ( + + ) : ()} + {row.projectNo} + {row.relation && ({getRelationLabel(row.relation)})} +
+ ); + }}, + { key: "status", label: "상태", width: "w-[80px]", align: "center" as const, render: (val: any) => {val} }, + { key: "name", label: "프로젝트명", width: "w-[200px]", render: (val: any, row: any) => { + const childCount = getAllDescendants(projects, row.id).length; + return (<>{val}{childCount > 0 && {childCount}}); + }}, + { key: "pm", label: "PM", width: "w-[70px]" }, + { key: "customer", label: "고객", width: "w-[80px]" }, + { key: "startDate", label: "시작일", width: "w-[90px]" }, + { key: "endDate", label: "종료예정", width: "w-[90px]" }, + { key: "progress", label: "진행률", width: "w-[100px]", align: "center" as const, render: (val: any) => ( +
+
+
+
+ {val}% +
+ )}, + { key: "sourceNo", label: "원접수번호", width: "w-[90px]", render: (val: any) => {val || "-"} }, + ] as EDataTableColumn[]} + data={ts.groupData(treeRows.map(({ project: p, depth }) => ({ ...p, _depth: depth })))} + rowKey={(row) => row.id} + loading={loading} + emptyMessage="조건에 맞는 프로젝트가 없어요" + selectedId={selectedId} + onSelect={(id) => { + if (id) { + setSelectedId(id); + setDetailTab("wbs"); + fetchTaskDetails(id); + } + }} + onRowClick={(row) => { + setSelectedId(row.id); + setDetailTab("wbs"); + fetchTaskDetails(row.id); + }} + showPagination={false} + draggableColumns={false} + />
diff --git a/frontend/app/(main)/COMPANY_16/design/task-management/page.tsx b/frontend/app/(main)/COMPANY_16/design/task-management/page.tsx index de76142f..f5603e46 100644 --- a/frontend/app/(main)/COMPANY_16/design/task-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/design/task-management/page.tsx @@ -57,6 +57,7 @@ import { Users, Settings2, } from "lucide-react"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { @@ -749,108 +750,49 @@ export default function DesignTaskManagementPage() { -
- - - - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {loading && allTasks.length === 0 ? ( - - -
- - 로딩 중... -
-
-
- ) : filteredData.length === 0 ? ( - - -
- - 조건에 맞는 업무가 없어요 -
-
-
- ) : ( - filteredData.map((item) => ( - handleSelectTask(item.dbId)} - > - {ts.isVisible("source_type") && ( - - - {item.sourceType === "dr" ? "DR" : "ECR"} - - - )} - {ts.isVisible("request_no") && ( - - {item.id} - - )} - {ts.isVisible("status") && ( - - - {item.status} - - - )} - {ts.isVisible("priority") && ( - - - {item.priority} - - - )} - {ts.isVisible("target_name") && {item.targetName}} - {ts.isVisible("req_dept") && {item.reqDept}} - {ts.isVisible("requester") && {item.requester}} - {ts.isVisible("request_date") && {item.date}} - {ts.isVisible("due_date") && {item.dueDate}} - {ts.isVisible("designer") && ( - - {item.designer || 미배정} - - )} - - )) - )} -
-
-
+ + columns={ts.visibleColumns.map((col): EDataTableColumn => ({ + key: col.key === "request_no" ? "id" : col.key === "target_name" ? "targetName" : col.key === "req_dept" ? "reqDept" : col.key === "request_date" ? "date" : col.key === "due_date" ? "dueDate" : col.key === "source_type" ? "sourceType" : col.key, + label: col.label, + width: col.key === "source_type" ? "w-[60px]" : col.key === "request_no" ? "w-[130px]" : col.key === "status" ? "w-[90px]" : col.key === "priority" ? "w-[80px]" : col.key === "target_name" ? "min-w-[180px]" : col.key === "req_dept" ? "w-[90px]" : col.key === "requester" ? "w-[80px]" : col.key === "request_date" ? "w-[100px]" : col.key === "due_date" ? "w-[100px]" : col.key === "designer" ? "w-[80px]" : undefined, + align: (col.key === "source_type" || col.key === "status" || col.key === "priority") ? "center" : undefined, + render: col.key === "source_type" + ? (val: any, row: TaskItem) => ( + + {row.sourceType === "dr" ? "DR" : "ECR"} + + ) + : col.key === "request_no" + ? (val: any, row: TaskItem) => ( + + {val} + + ) + : col.key === "status" + ? (val: any) => ( + + {val} + + ) + : col.key === "priority" + ? (val: any) => ( + + {val} + + ) + : col.key === "designer" + ? (val: any) => val ? {val} : 미배정 + : undefined, + }))} + data={ts.groupData(filteredData)} + loading={loading} + emptyMessage="조건에 맞는 업무가 없어요" + rowKey={(row) => row.dbId} + selectedId={selectedTaskId} + onSelect={(id) => handleSelectTask(id ?? "")} + draggableColumns={false} + showPagination={false} + /> diff --git a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx index 7ec2b68c..eeb63844 100644 --- a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx @@ -8,7 +8,7 @@ * 점검항목 복사 기능 포함 */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -34,6 +34,7 @@ import { ImageUpload } from "@/components/common/ImageUpload"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const EQUIP_TABLE = "equipment_mng"; const INSPECTION_TABLE = "equipment_inspection_item"; @@ -140,6 +141,17 @@ export default function EquipmentInfoPage() { return catOptions[col]?.find((o) => o.code === code)?.label || code; }; + const mainTableColumns = useMemo(() => { + const cols: EDataTableColumn[] = []; + if (ts.isVisible("equipment_code")) cols.push({ key: "equipment_code", label: "설비코드", width: "w-[110px]" }); + if (ts.isVisible("equipment_name")) cols.push({ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" }); + if (ts.isVisible("equipment_type")) cols.push({ key: "equipment_type", label: "설비유형", width: "w-[90px]", render: (v) => v || "-" }); + if (ts.isVisible("manufacturer")) cols.push({ key: "manufacturer", label: "제조사", width: "w-[100px]", render: (v) => v || "-" }); + if (ts.isVisible("installation_location")) cols.push({ key: "installation_location", label: "설치장소", width: "w-[100px]", render: (v) => v || "-" }); + if (ts.isVisible("operation_status")) cols.push({ key: "operation_status", label: "가동상태", width: "w-[80px]", render: (v) => v || "-" }); + return cols; + }, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps + // 설비 조회 const fetchEquipments = useCallback(async () => { setEquipLoading(true); @@ -469,48 +481,18 @@ export default function EquipmentInfoPage() { -
- {equipLoading ? ( -
- -
- ) : equipments.length === 0 ? ( -
- -

등록된 설비가 없어요

-
- ) : ( - - - - {ts.isVisible("equipment_code") && 설비코드} - {ts.isVisible("equipment_name") && 설비명} - {ts.isVisible("equipment_type") && 설비유형} - {ts.isVisible("manufacturer") && 제조사} - {ts.isVisible("installation_location") && 설치장소} - {ts.isVisible("operation_status") && 가동상태} - - - - {equipments.map((equip) => ( - setSelectedEquipId(equip.id)} - onDoubleClick={openEquipEdit} - > - {ts.isVisible("equipment_code") && {equip.equipment_code}} - {ts.isVisible("equipment_name") && {equip.equipment_name || "-"}} - {ts.isVisible("equipment_type") && {equip.equipment_type || "-"}} - {ts.isVisible("manufacturer") && {equip.manufacturer || "-"}} - {ts.isVisible("installation_location") && {equip.installation_location || "-"}} - {ts.isVisible("operation_status") && {equip.operation_status || "-"}} - - ))} - -
- )} -
+ setSelectedEquipId(id)} + onRowDoubleClick={() => openEquipEdit()} + showPagination={true} + draggableColumns={false} + columnOrderKey="c16-equipment-info-main" + /> diff --git a/frontend/app/(main)/COMPANY_16/equipment/plc-settings/page.tsx b/frontend/app/(main)/COMPANY_16/equipment/plc-settings/page.tsx index 5a3cd862..4e94d293 100644 --- a/frontend/app/(main)/COMPANY_16/equipment/plc-settings/page.tsx +++ b/frontend/app/(main)/COMPANY_16/equipment/plc-settings/page.tsx @@ -19,6 +19,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; @@ -285,46 +286,25 @@ export default function PlcSettingsPage() {
- - - - - 0 && dtChecked.length === datatypes.length} - onCheckedChange={(v) => setDtChecked(v ? datatypes.map(r => r.id) : [])} - /> - - {ts.visibleColumns.map((col) => ( - {col.label} - ))} - - - - {dtLoading ? ( - - ) : datatypes.length === 0 ? ( -

등록된 PLC 데이터타입이 없어요

- ) : datatypes.map((row) => ( - setDtChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} - onDoubleClick={() => openDtEdit(row)} - > - e.stopPropagation()}> - setDtChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> - - {ts.visibleColumns.map((col) => ( - - {col.key === "is_active" - ? {row.is_active ? "사용" : "미사용"} - : row[col.key] ?? ""} - - ))} - - ))} -
-
+ ({ + key: col.key, + label: col.label, + align: col.key === "is_active" ? "center" : undefined, + render: col.key === "is_active" + ? (val: any) => {val ? "사용" : "미사용"} + : undefined, + }))} + data={ts.groupData(datatypes)} + loading={dtLoading} + emptyMessage="등록된 PLC 데이터타입이 없어요" + showCheckbox + checkedIds={dtChecked} + onCheckedChange={setDtChecked} + onRowDoubleClick={(row) => openDtEdit(row)} + showPagination={false} + draggableColumns={false} + />
@@ -360,52 +340,26 @@ export default function PlcSettingsPage() {
- - - - - 0 && cfgChecked.length === configs.length} - onCheckedChange={(v) => setCfgChecked(v ? configs.map(r => r.id) : [])} - /> - - 설정명 - 소스연결ID - 소스테이블 - 대상테이블 - 수집유형 - 스케줄(Cron) - 사용여부 - - - - {cfgLoading ? ( - - ) : configs.length === 0 ? ( -

등록된 수집 설정이 없어요

- ) : configs.map((row) => ( - setCfgChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} - onDoubleClick={() => openCfgEdit(row)} - > - e.stopPropagation()}> - setCfgChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> - - {row.config_name} - {row.source_connection_id} - {row.source_table} - {row.target_table} - {row.collection_type} - {row.schedule_cron} - - {row.is_active ? "사용" : "미사용"} - - - ))} -
-
+ {val} }, + { key: "is_active", label: "사용여부", width: "w-[80px]", align: "center" as const, render: (val: any) => {val ? "사용" : "미사용"} }, + ] as EDataTableColumn[]} + data={configs} + loading={cfgLoading} + emptyMessage="등록된 수집 설정이 없어요" + showCheckbox + checkedIds={cfgChecked} + onCheckedChange={setCfgChecked} + onRowDoubleClick={(row) => openCfgEdit(row)} + showPagination={false} + draggableColumns={false} + />
diff --git a/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx index 1e60f516..543dd3dd 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/info/page.tsx @@ -54,6 +54,7 @@ import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numbering import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // ========== 타입 & 상수 ========== type TabKey = "carrier" | "cost" | "contract" | "route" | "vehicle"; @@ -822,95 +823,25 @@ export default function LogisticsInfoPage() { {/* 테이블 영역 */}
- {tabLoading[tab.key] ? ( -
- - 불러오는 중... -
- ) : displayData.length === 0 ? ( -
-
- -
- 등록된 {tab.label} 정보가 없어요 - - 등록 버튼을 눌러 새 항목을 추가해주세요 - -
- ) : ( - - - - - - toggleAllCheck(tab.key, !!checked) - } - /> - - {getVisibleColumns(tab.key).map((col) => ( - - {col.label} - - ))} - - - - {displayData.map((row: any, idx: number) => { - const rowId = String(row.id); - const isChecked = tabChecked[tab.key].includes(rowId); - return ( - toggleRowCheck(tab.key, rowId)} - onDoubleClick={() => handleOpenEdit(row)} - > - - toggleRowCheck(tab.key, rowId)} - onClick={(e) => e.stopPropagation()} - /> - - {getVisibleColumns(tab.key).map((col) => { - const val = row[col.key]; - const display = - col.formatNumber && val != null && val !== "" - ? Number(val).toLocaleString() - : val ?? ""; - return ( - - {display} - - ); - })} - - ); - })} - -
- )} + ({ + key: col.key, + label: col.label, + align: col.align, + formatNumber: col.formatNumber, + truncate: true, + }))} + data={tsMap[tab.key].groupData(displayData)} + rowKey={(row: any) => String(row.id)} + loading={tabLoading[tab.key]} + emptyMessage={`등록된 ${tab.label} 정보가 없어요`} + showCheckbox + checkedIds={tabChecked[tab.key]} + onCheckedChange={(ids) => setTabChecked((prev) => ({ ...prev, [tab.key]: ids }))} + onRowDoubleClick={(row) => handleOpenEdit(row)} + showPagination={false} + draggableColumns={false} + />
); diff --git a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx index 5562ef65..7d2a96c6 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx @@ -59,6 +59,7 @@ import { useAuth } from "@/hooks/useAuth"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { toast } from "sonner"; import { exportToExcel } from "@/lib/utils/excelExport"; @@ -312,6 +313,45 @@ export default function InventoryStatusPage() { } }; + // EDataTable 컬럼 정의 + const stockColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => { + const base: EDataTableColumn = { key: col.key, label: col.label, align: col.align }; + if (col.key === "current_qty") { + return { + ...base, + align: "right" as const, + render: (val: any, row: any) => ( + + + {Number(row.current_qty || 0).toLocaleString()} + + {row._isLow && ( + + )} + + ), + }; + } + if (col.key === "safety_qty") { + return { + ...base, + align: "right" as const, + formatNumber: true, + }; + } + if (col.key === "status") { + return { + ...base, + render: (val: any) => ( + + {val} + + ), + }; + } + return base; + }); + // 엑셀 내보내기 const handleExcelExport = () => { if (stockItems.length === 0) { @@ -377,86 +417,19 @@ export default function InventoryStatusPage() { -
- {stockLoading ? ( -
- -
- ) : stockItems.length === 0 ? ( -
- 등록된 재고가 없어요 -
- ) : ( - - - - # - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {stockItems.map((item, idx) => ( - setSelectedStockId(item.id)} - > - - {idx + 1} - - {ts.visibleColumns.map((col) => { - if (col.key === "current_qty") { - return ( - - - {Number(item.current_qty || 0).toLocaleString()} - - {item._isLow && ( - - )} - - ); - } - if (col.key === "safety_qty") { - return ( - - {Number(item.safety_qty || 0).toLocaleString()} - - ); - } - if (col.key === "status") { - return ( - - - {item.status} - - - ); - } - return ( - - {item[col.key] ?? ""} - - ); - })} - - ))} - -
- )} -
+ row.id} + loading={stockLoading} + emptyMessage="등록된 재고가 없어요" + selectedId={selectedStockId} + onSelect={(id) => setSelectedStockId(id)} + showRowNumber + showPagination={false} + draggableColumns={false} + columnOrderKey="c16-inventory" + /> diff --git a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx index 212f3903..49e3f6cf 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/outbound/page.tsx @@ -47,6 +47,7 @@ import { toast } from "sonner"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // API: /outbound/* import { getOutboundList, @@ -643,139 +644,40 @@ export default function OutboundPage() { -
- - - - - - - {ts.isVisible("outbound_number") && 출고번호} - {ts.isVisible("outbound_type") && 출고유형} - {ts.isVisible("outbound_date") && 출고일} - {ts.isVisible("reference_number") && 참조번호} - {ts.isVisible("source_type") && 데이터출처} - {ts.isVisible("customer_name") && 거래처} - {ts.isVisible("item_number") && 품목코드} - {ts.isVisible("item_name") && 품목명} - {ts.isVisible("spec") && 규격} - {ts.isVisible("outbound_qty") && 출고수량} - {ts.isVisible("unit_price") && 단가} - {ts.isVisible("total_amount") && 금액} - {ts.isVisible("warehouse_name") && 창고} - {ts.isVisible("outbound_status") && 출고상태} - {ts.isVisible("remark") && 비고} - - - - {loading ? ( - - - - - - ) : data.length === 0 ? ( - - -
- -

등록된 출고 내역이 없어요

-

- 출고 등록 버튼을 클릭하여 출고를 추가해주세요 -

-
-
-
- ) : ( - data.map((row) => ( - toggleCheck(row.id)} - onDoubleClick={() => openEditModal(row)} - > - e.stopPropagation()} - > - toggleCheck(row.id)} - /> - - {ts.isVisible("outbound_number") && - {row.outbound_number} - } - {ts.isVisible("outbound_type") && - - {row.outbound_type || "-"} - - } - {ts.isVisible("outbound_date") && - {row.outbound_date - ? new Date(row.outbound_date).toLocaleDateString("ko-KR") - : "-"} - } - {ts.isVisible("reference_number") && - {row.reference_number || "-"} - } - {ts.isVisible("source_type") && - {row.source_type - ? SOURCE_TYPE_LABEL[row.source_type] || row.source_type - : "-"} - } - {ts.isVisible("customer_name") && - {row.customer_name || "-"} - } - {ts.isVisible("item_number") && - {row.item_code || "-"} - } - {ts.isVisible("item_name") && {row.item_name || "-"}} - {ts.isVisible("spec") && {row.specification || "-"}} - {ts.isVisible("outbound_qty") && - {Number(row.outbound_qty || 0).toLocaleString()} - } - {ts.isVisible("unit_price") && - {Number(row.unit_price || 0).toLocaleString()} - } - {ts.isVisible("total_amount") && - {Number(row.total_amount || 0).toLocaleString()} - } - {ts.isVisible("warehouse_name") && - {row.warehouse_name || row.warehouse_code || "-"} - } - {ts.isVisible("outbound_status") && - - {row.outbound_status || "-"} - - } - {ts.isVisible("remark") && - {row.memo || "-"} - } - - )) - )} -
-
-
+ ( + {v || "-"} + )}, + { key: "outbound_date", label: "출고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" }, + { key: "reference_number", label: "참조번호", width: "w-[120px]" }, + { key: "source_type", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TYPE_LABEL[v] || v : "-" }, + { key: "customer_name", label: "거래처", width: "w-[120px]" }, + { key: "item_code", label: "품목코드", width: "w-[100px]" }, + { key: "item_name", label: "품목명", minWidth: "min-w-[150px]" }, + { key: "specification", label: "규격", width: "w-[80px]" }, + { key: "outbound_qty", label: "출고수량", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true }, + { key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" }, + { key: "outbound_status", label: "출고상태", width: "w-[90px]", align: "center", render: (v) => ( + {v || "-"} + )}, + { key: "memo", label: "비고", width: "w-[100px]" }, + ] as EDataTableColumn[]} + data={ts.groupData(data)} + rowKey={(row) => row.id} + loading={loading} + emptyMessage="등록된 출고 내역이 없어요" + showCheckbox + checkedIds={checkedIds} + onCheckedChange={setCheckedIds} + onRowDoubleClick={(row) => openEditModal(row)} + showPagination + draggableColumns + columnOrderKey="c16-outbound" + /> {/* 출고 등록 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx index 1a04aa7f..5d4d5787 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx @@ -30,6 +30,7 @@ import { import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const GRID_COLUMNS = [ { key: "pkg_code", label: "품목코드" }, @@ -458,58 +459,32 @@ export default function PackagingPage() {
{/* 포장재 목록 테이블 */}
- - - - {ts.isVisible("pkg_code") && 품목코드} - {ts.isVisible("pkg_name") && 포장명} - {ts.isVisible("pkg_type") && 유형} - {ts.isVisible("size") && 크기(mm)} - {ts.isVisible("max_weight") && 최대중량} - {ts.isVisible("status") && 상태} - - - - {pkgLoading ? ( - - - - - - ) : filteredPkgUnits.length === 0 ? ( - - -
-
- -
-

등록된 포장재가 없어요

-
-
-
- ) : filteredPkgUnits.map((p) => ( - selectPkg(p)} - > - {ts.isVisible("pkg_code") && {p.pkg_code}} - {ts.isVisible("pkg_name") && {p.pkg_name}} - {ts.isVisible("pkg_type") && {PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}} - {ts.isVisible("size") && {fmtSize(p.width_mm, p.length_mm, p.height_mm)}} - {ts.isVisible("max_weight") && {Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}} - {ts.isVisible("status") && - - {STATUS_LABEL[p.status] || p.status} - - } - - ))} -
-
+ PKG_TYPE_LABEL[v] || v || "-" }, + { key: "size", label: "크기(mm)", width: "w-[100px]", render: (_v, row) => fmtSize(row.width_mm, row.length_mm, row.height_mm) }, + { key: "max_load_kg", label: "최대중량", width: "w-[80px]", align: "right", render: (v) => Number(v || 0) > 0 ? `${v}kg` : "-" }, + { key: "status", label: "상태", width: "w-[60px]", align: "center", render: (v) => ( + + {STATUS_LABEL[v] || v} + + )}, + ] as EDataTableColumn[]} + data={ts.groupData(filteredPkgUnits)} + rowKey={(row) => String(row.id)} + loading={pkgLoading} + emptyMessage="등록된 포장재가 없어요" + selectedId={selectedPkg ? String(selectedPkg.id) : null} + onSelect={(id) => { + const pkg = filteredPkgUnits.find((p) => String(p.id) === id); + if (pkg) selectPkg(pkg); + }} + showPagination={false} + draggableColumns + columnOrderKey="c16-packaging-pkg" + />
{/* 매칭 품목 서브패널 */} diff --git a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx index 0287cb14..42590754 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx @@ -53,6 +53,7 @@ import { apiClient } from "@/lib/api/client"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // API: /receiving/* import { getReceivingList, @@ -574,135 +575,39 @@ export default function ReceivingPage() {
-
- - - - - - - {ts.isVisible("inbound_number") && 입고번호} - {ts.isVisible("inbound_type") && 입고유형} - {ts.isVisible("inbound_date") && 입고일} - {ts.isVisible("reference_number") && 참조번호} - {ts.isVisible("source_type") && 데이터출처} - {ts.isVisible("supplier_name") && 공급처} - {ts.isVisible("item_number") && 품목코드} - {ts.isVisible("item_name") && 품목명} - {ts.isVisible("spec") && 규격} - {ts.isVisible("inbound_qty") && 입고수량} - {ts.isVisible("unit_price") && 단가} - {ts.isVisible("total_amount") && 금액} - {ts.isVisible("warehouse_name") && 창고} - {ts.isVisible("inbound_status") && 입고상태} - {ts.isVisible("remark") && 비고} - - - - {loading ? ( - - - - - - ) : data.length === 0 ? ( - - -
- -

등록된 입고 내역이 없어요

-

- 입고 등록 버튼을 클릭하여 입고를 추가해 보세요 -

-
-
-
- ) : ( - data.map((row) => ( - toggleCheck(row.id)} - > - e.stopPropagation()} - > - toggleCheck(row.id)} - /> - - {ts.isVisible("inbound_number") && - {row.inbound_number} - } - {ts.isVisible("inbound_type") && - - {row.inbound_type || "-"} - - } - {ts.isVisible("inbound_date") && - {row.inbound_date - ? new Date(row.inbound_date).toLocaleDateString("ko-KR") - : "-"} - } - {ts.isVisible("reference_number") && - {row.reference_number || "-"} - } - {ts.isVisible("source_type") && - {row.source_table - ? SOURCE_TABLE_LABEL[row.source_table] || row.source_table - : "-"} - } - {ts.isVisible("supplier_name") && - {row.supplier_name || "-"} - } - {ts.isVisible("item_number") && - {row.item_number || "-"} - } - {ts.isVisible("item_name") && {row.item_name || "-"}} - {ts.isVisible("spec") && {row.spec || "-"}} - {ts.isVisible("inbound_qty") && - {Number(row.inbound_qty || 0).toLocaleString()} - } - {ts.isVisible("unit_price") && - {Number(row.unit_price || 0).toLocaleString()} - } - {ts.isVisible("total_amount") && - {Number(row.total_amount || 0).toLocaleString()} - } - {ts.isVisible("warehouse_name") && - {row.warehouse_name || row.warehouse_code || "-"} - } - {ts.isVisible("inbound_status") && - - {row.inbound_status || "-"} - - } - {ts.isVisible("remark") && - {row.memo || "-"} - } - - )) - )} -
-
-
+ ( + {v || "-"} + )}, + { key: "inbound_date", label: "입고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" }, + { key: "reference_number", label: "참조번호", width: "w-[120px]" }, + { key: "source_table", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TABLE_LABEL[v] || v : "-" }, + { key: "supplier_name", label: "공급처", width: "w-[120px]" }, + { key: "item_number", label: "품목코드", width: "w-[100px]" }, + { key: "item_name", label: "품목명", minWidth: "min-w-[150px]" }, + { key: "spec", label: "규격", width: "w-[80px]" }, + { key: "inbound_qty", label: "입고수량", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true }, + { key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" }, + { key: "inbound_status", label: "입고상태", width: "w-[90px]", align: "center", render: (v) => ( + {v || "-"} + )}, + { key: "memo", label: "비고", width: "w-[100px]" }, + ] as EDataTableColumn[]} + data={ts.groupData(data)} + rowKey={(row) => row.id} + loading={loading} + emptyMessage="등록된 입고 내역이 없어요" + showCheckbox + checkedIds={checkedIds} + onCheckedChange={setCheckedIds} + showPagination + draggableColumns + columnOrderKey="c16-receiving" + /> {/* 입고 등록 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx index 30544ddb..96b3d47e 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/warehouse/page.tsx @@ -64,6 +64,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { exportToExcel } from "@/lib/utils/excelExport"; import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; @@ -691,6 +692,32 @@ export default function WarehouseManagementPage() { maxLevels: rackConditions.reduce((acc, c) => Math.max(acc, c.levels || 0), 0), }; + // EDataTable 컬럼 정의 + const warehouseColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => { + const base: EDataTableColumn = { key: col.key, label: col.label }; + if (col.key === "warehouse_type") { + return { + ...base, + render: (val: any) => ( + + {val} + + ), + }; + } + if (col.key === "status") { + return { + ...base, + render: (val: any) => ( + + {val} + + ), + }; + } + return base; + }); + // 엑셀 내보내기 const handleExcelExport = () => { if (warehouses.length === 0) { @@ -775,70 +802,20 @@ export default function WarehouseManagementPage() { -
- {warehouseLoading ? ( -
- -
- ) : warehouses.length === 0 ? ( -
- 등록된 창고가 없어요 -
- ) : ( - - - - # - {ts.visibleColumns.map((col) => ( - {col.label} - ))} - - - - {warehouses.map((w, idx) => ( - setSelectedWarehouseId(w.id)} - onDoubleClick={() => openWarehouseEditModal(w)} - > - - {idx + 1} - - {ts.visibleColumns.map((col) => { - if (col.key === "warehouse_type") { - return ( - - - {w.warehouse_type} - - - ); - } - if (col.key === "status") { - return ( - - - {w.status} - - - ); - } - return ( - - {w[col.key] ?? ""} - - ); - })} - - ))} - -
- )} -
+ row.id} + loading={warehouseLoading} + emptyMessage="등록된 창고가 없어요" + selectedId={selectedWarehouseId} + onSelect={(id) => setSelectedWarehouseId(id)} + onRowDoubleClick={(row) => openWarehouseEditModal(row)} + showRowNumber + showPagination={false} + draggableColumns={false} + columnOrderKey="c16-warehouse" + /> diff --git a/frontend/app/(main)/COMPANY_16/master-data/company/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/company/page.tsx index 6f48424b..dfd1b666 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/company/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/company/page.tsx @@ -31,6 +31,7 @@ import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { formatField, validateField, validateForm } from "@/lib/utils/validation"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const COMPANY_TABLE = "company_mng"; const DEPT_TABLE = "dept_info"; @@ -376,6 +377,16 @@ export default function CompanyPage() { } }; + // EDataTable 컬럼 정의 (사원 목록) + const companyMemberColumns: EDataTableColumn[] = [ + { key: "sabun", label: "사번", width: "w-[80px]", render: (val: any) => {val || "-"} }, + { key: "user_name", label: "이름", width: "w-[90px]" }, + { key: "user_id", label: "사용자ID", width: "w-[100px]" }, + { key: "position_name", label: "직급", width: "w-[80px]", render: (val: any) => {val || "-"} }, + { key: "cell_phone", label: "휴대폰", width: "w-[120px]", render: (val: any) => {val || "-"} }, + { key: "email", label: "이메일" }, + ]; + /* ── 트리 렌더 ── */ const renderTree = (nodes: DeptNode[], depth = 0) => { return nodes.map((node) => { @@ -685,47 +696,17 @@ export default function CompanyPage() { )} {selectedDeptCode ? ( -
- {memberLoading ? ( -
- -
- ) : members.length === 0 ? ( -
- - 소속 사원이 없어요 -
- ) : ( - - - - 사번 - 이름 - 사용자ID - 직급 - 휴대폰 - 이메일 - - - - {members.map((row) => ( - openUserModal(row)} - > - {row.sabun || "-"} - {row.user_name} - {row.user_id} - {row.position_name || "-"} - {row.cell_phone || "-"} - {row.email || "-"} - - ))} - -
- )} -
+ row.user_id || row.id} + loading={memberLoading} + emptyMessage="소속 사원이 없어요" + emptyIcon={} + onRowDoubleClick={(row) => openUserModal(row)} + showPagination={false} + draggableColumns={false} + /> ) : (
diff --git a/frontend/app/(main)/COMPANY_16/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/department/page.tsx index a4cbb7ed..a2bbcba5 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/department/page.tsx @@ -39,6 +39,7 @@ import { formatField, validateField, validateForm } from "@/lib/utils/validation import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const DEPT_TABLE = "dept_info"; const USER_TABLE = "user_info"; @@ -313,6 +314,36 @@ export default function DepartmentPage() { const isColVisible = (key: string) => ts.isVisible(key); + // EDataTable 컬럼 정의 (부서 목록) + const deptColumns: EDataTableColumn[] = [ + { key: "dept_code", label: "부서코드", width: "w-[120px]" }, + { key: "dept_name", label: "부서명", minWidth: "min-w-[140px]" }, + ...(isColVisible("parent_dept_code") + ? [{ + key: "parent_dept_code", + label: "상위부서", + width: "w-[110px]", + render: (val: any) => {val || "\u2014"}, + }] + : []), + ...(isColVisible("status") + ? [{ + key: "status", + label: "상태", + width: "w-[70px]", + render: (val: any) => + val ? ( + + {val === "active" ? "활성" : (val || "\u2014")} + + ) : null, + }] + : []), + ]; + return (
{/* 검색 필터 바 */} @@ -366,61 +397,20 @@ export default function DepartmentPage() {
{/* 부서 테이블 */} -
- - - - No - 부서코드 - 부서명 - {isColVisible("parent_dept_code") && 상위부서} - {isColVisible("status") && 상태} - - - - {deptLoading ? ( - - - - - - ) : depts.length === 0 ? ( - - - 등록된 부서가 없어요 - - - ) : depts.map((dept, idx) => ( - setSelectedDeptId((prev) => prev === dept.id ? null : dept.id)} - onDoubleClick={openDeptEdit} - > - {idx + 1} - {dept.dept_code} - {dept.dept_name} - {isColVisible("parent_dept_code") && {dept.parent_dept_code || "—"}} - {isColVisible("status") && ( - - {dept.status && ( - - {dept.status === "active" ? "활성" : (dept.status || "—")} - - )} - - )} - - ))} - -
-
+ row.id} + loading={deptLoading} + emptyMessage="등록된 부서가 없어요" + selectedId={selectedDeptId} + onSelect={(id) => setSelectedDeptId(id)} + onRowDoubleClick={() => openDeptEdit()} + showRowNumber + showPagination={false} + draggableColumns={false} + columnOrderKey="c16-department" + />
diff --git a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx index d818ada2..3318f70d 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/item-info/page.tsx @@ -36,6 +36,7 @@ import { Pencil, Copy, Settings2, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; @@ -335,64 +336,21 @@ export default function ItemInfoPage() { {/* 메인 테이블 */} -
- {loading ? ( -
- -
- ) : items.length === 0 ? ( -
- 등록된 품목이 없어요 -
- ) : ( - - - - # - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {items.map((item, idx) => ( - setSelectedId(item.id)} - onDoubleClick={() => openEditModal(item)} - > - {idx + 1} - {ts.visibleColumns.map((col) => ( - - {item[col.key] ?? ""} - - ))} - - ))} - -
- )} -
+ ({ + key: col.key, + label: col.label, + align: col.align as "left" | "center" | "right" | undefined, + }))} + data={ts.groupData(items)} + loading={loading} + emptyMessage="등록된 품목이 없어요" + selectedId={selectedId} + onSelect={(id) => setSelectedId(id)} + onRowDoubleClick={(row) => openEditModal(row)} + showRowNumber + draggableColumns={false} + /> {/* 등록/수정 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_16/master-data/options/page.tsx index 91aa75ef..7506ad94 100644 --- a/frontend/app/(main)/COMPANY_16/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_16/master-data/options/page.tsx @@ -110,7 +110,7 @@ export default function OptionsSettingPage() { {selectedColumn && selectedTableName ? ( ) : ( diff --git a/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx index c4349c90..929d35c1 100644 --- a/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page.tsx @@ -9,7 +9,7 @@ * 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블) */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -31,6 +31,7 @@ import { exportToExcel } from "@/lib/utils/excelExport"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const ITEM_TABLE = "item_info"; const MAPPING_TABLE = "subcontractor_item_mapping"; @@ -113,6 +114,19 @@ export default function SubcontractorItemPage() { return categoryOptions[col]?.find((o) => o.code === code)?.label || code; }; + const mainTableColumns = useMemo(() => { + const cols: EDataTableColumn[] = []; + if (ts.isVisible("item_number")) cols.push({ key: "item_number", label: "품번", width: "w-[110px]" }); + if (ts.isVisible("item_name")) cols.push({ key: "item_name", label: "품명", minWidth: "min-w-[130px]", render: (v) => v || "-" }); + if (ts.isVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" }); + if (ts.isVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" }); + if (ts.isVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true }); + if (ts.isVisible("selling_price")) cols.push({ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true }); + if (ts.isVisible("currency_code")) cols.push({ key: "currency_code", label: "통화", width: "w-[50px]", render: (v) => v || "-" }); + if (ts.isVisible("status")) cols.push({ key: "status", label: "상태", width: "w-[60px]", render: (v) => v || "-" }); + return cols; + }, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps + // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( (o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주") @@ -337,52 +351,18 @@ export default function SubcontractorItemPage() { -
- {itemLoading ? ( -
- -
- ) : items.length === 0 ? ( -
- -

등록된 외주품목이 없어요

-
- ) : ( - - - - {ts.isVisible("item_number") && 품번} - {ts.isVisible("item_name") && 품명} - {ts.isVisible("size") && 규격} - {ts.isVisible("unit") && 단위} - {ts.isVisible("standard_price") && 기준단가} - {ts.isVisible("selling_price") && 판매가격} - {ts.isVisible("currency_code") && 통화} - {ts.isVisible("status") && 상태} - - - - {items.map((item) => ( - setSelectedItemId(item.id)} - onDoubleClick={openEditItem} - > - {ts.isVisible("item_number") && {item.item_number}} - {ts.isVisible("item_name") && {item.item_name || "-"}} - {ts.isVisible("size") && {item.size || "-"}} - {ts.isVisible("unit") && {item.unit || "-"}} - {ts.isVisible("standard_price") && {formatNum(item.standard_price)}} - {ts.isVisible("selling_price") && {formatNum(item.selling_price)}} - {ts.isVisible("currency_code") && {item.currency_code || "-"}} - {ts.isVisible("status") && {item.status || "-"}} - - ))} - -
- )} -
+ setSelectedItemId(id)} + onRowDoubleClick={() => openEditItem()} + showPagination={true} + draggableColumns={false} + columnOrderKey="c16-subcontractor-item-main" + /> diff --git a/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx index 24c3caee..2baef4e0 100644 --- a/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx +++ b/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -32,6 +32,7 @@ import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numbering import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const SUBCONTRACTOR_TABLE = "subcontractor_mng"; const MAPPING_TABLE = "subcontractor_item_mapping"; @@ -172,6 +173,14 @@ export default function SubcontractorManagementPage() { return val; }; + const mainTableColumns = useMemo(() => { + return ts.visibleColumns.map((col) => ({ + key: col.key, + label: col.label, + render: (value: any, row: any) => renderCellValue(row, col.key), + })); + }, [ts.visibleColumns, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps + // 외주업체 목록 조회 const fetchSubcontractors = useCallback(async () => { setSubcontractorLoading(true); @@ -866,49 +875,18 @@ export default function SubcontractorManagementPage() { -
- {subcontractorLoading ? ( -
- -
- ) : subcontractors.length === 0 ? ( -
- -

등록된 외주업체가 없어요

-
- ) : ( - - - - {ts.visibleColumns.map((col) => ( - {col.label} - ))} - - - - {subcontractors.map((sub) => ( - setSelectedSubcontractorId(sub.id)} - onDoubleClick={openSubcontractorEdit} - > - {ts.visibleColumns.map((col) => ( - - {renderCellValue(sub, col.key)} - - ))} - - ))} - -
- )} -
+ setSelectedSubcontractorId(id)} + onRowDoubleClick={() => openSubcontractorEdit()} + showPagination={true} + draggableColumns={false} + columnOrderKey="c16-subcontractor-main" + /> diff --git a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx index d3fb7be1..1cf6eb63 100644 --- a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx @@ -65,6 +65,7 @@ import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { exportToExcel } from "@/lib/utils/excelExport"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; // ─── 상수 ─────────────────────────────────────── const BOM_TABLE = "bom"; @@ -945,71 +946,31 @@ export default function BomManagementPage() { {/* BOM 목록 테이블 */}
- {loading ? ( -
- -
- ) : bomList.length === 0 ? ( -
- -

등록된 BOM이 없어요

-
- ) : ( - - - - - 0} - onCheckedChange={(checked) => - setCheckedIds(checked ? bomList.map((r) => r.id) : []) - } - /> - - {ts.visibleColumns.map((col) => ( - {col.label} - ))} - - - - {bomList.map((row) => ( - setSelectedBomId(row.id)} - > - e.stopPropagation()}> - - setCheckedIds((prev) => - checked ? [...prev, row.id] : prev.filter((id) => id !== row.id) - ) - } - /> - - {ts.visibleColumns.map((col) => { - if (col.key === "item_code") { - return {row.item_code || row.item_number || "-"}; - } - if (col.key === "bom_type") { - return {BOM_TYPE_OPTIONS.find((o) => o.code === row.bom_type)?.label || row.bom_type || "-"}; - } - if (col.key === "status") { - return {renderStatusBadge(row.status)}; - } - return {row[col.key] || "-"}; - })} - - ))} - -
- )} + ({ + key: col.key, + label: col.label, + render: col.key === "item_code" + ? (_val: any, row: any) => {row.item_code || row.item_number || "-"} + : col.key === "bom_type" + ? (_val: any, row: any) => {BOM_TYPE_OPTIONS.find((o) => o.code === row.bom_type)?.label || row.bom_type || "-"} + : col.key === "status" + ? (_val: any, row: any) => renderStatusBadge(row.status) + : undefined, + }))} + data={ts.groupData(bomList)} + loading={loading} + emptyMessage="등록된 BOM이 없어요" + showCheckbox + checkedIds={checkedIds} + onCheckedChange={setCheckedIds} + selectedId={selectedBomId} + onSelect={(id) => setSelectedBomId(id)} + onRowClick={(row) => setSelectedBomId(row.id)} + showPagination + draggableColumns={false} + columnOrderKey="c16-bom" + />
diff --git a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx index aeff278d..32f7b2cd 100644 --- a/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/plan-management/page.tsx @@ -1040,8 +1040,25 @@ export default function ProductionPlanManagementPage() { - {orderItems.map((item) => ( - + {ts.groupData(orderItems).map((item, rowIdx) => { + if (item._isGroupSummary) { + return ( + + + + {ts.visibleColumns.map((col) => { + const v = (item as any)[col.key]; + return ( + + {typeof v === "number" ? Number(v).toLocaleString() : (v || "")} + + ); + })} + + ); + } + return ( + e.stopPropagation()}> toggleItemGroupSelect(item.item_code)} className="h-4 w-4" /> @@ -1093,7 +1110,8 @@ export default function ProductionPlanManagementPage() { ))} - ))} + ); + })} diff --git a/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx index 9acf4e17..a5b136c2 100644 --- a/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_16/production/process-info/ItemRoutingTab.tsx @@ -13,6 +13,7 @@ import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { Dialog, DialogContent, @@ -684,62 +685,27 @@ export function ItemRoutingTab() {
{!selectedVersionId ? (

버전을 선택해주세요

- ) : detailsLoading ? ( -
- -

불러오는 중...

-
- ) : detailsGridData.length === 0 ? ( -

등록된 공정이 없어요

) : ( - - - - - 0} - onCheckedChange={(checked) => { - if (checked) setSelectedDetailIds(new Set(detailsGridData.map((r) => r.id))); - else setSelectedDetailIds(new Set()); - }} - /> - - 순번 - 공정명 - 필수 - 순서고정 - 작업구분 - 표준시간 - 외주업체 - - - - {detailsGridData.map((row) => ( - - e.stopPropagation()}> - { - setSelectedDetailIds((prev) => { - const next = new Set(prev); - if (checked) next.add(row.id); - else next.delete(row.id); - return next; - }); - }} - /> - - {row.seq_no} - {row.process_display} - {row.is_required} - {row.is_fixed_order} - {row.work_type} - {row.standard_time} - {row.outsource_display} - - ))} - -
+ row.id} + loading={detailsLoading} + emptyMessage="등록된 공정이 없어요" + showCheckbox + checkedIds={Array.from(selectedDetailIds)} + onCheckedChange={(ids) => setSelectedDetailIds(new Set(ids))} + showPagination={false} + draggableColumns={false} + /> )}
diff --git a/frontend/app/(main)/COMPANY_16/production/process-info/ProcessMasterTab.tsx b/frontend/app/(main)/COMPANY_16/production/process-info/ProcessMasterTab.tsx index 34244cc9..cfbee962 100644 --- a/frontend/app/(main)/COMPANY_16/production/process-info/ProcessMasterTab.tsx +++ b/frontend/app/(main)/COMPANY_16/production/process-info/ProcessMasterTab.tsx @@ -46,6 +46,7 @@ import { TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { getProcessList, createProcess, @@ -435,69 +436,34 @@ export function ProcessMasterTab() { {/* 공정 목록 테이블 */}
- {listBusy ? ( -
- -

불러오는 중...

-
- ) : processGridData.length === 0 ? ( -

조회된 공정이 없어요

- ) : ( - - - - - 0} - onCheckedChange={(checked) => { - if (checked) setSelectedIds(new Set(processGridData.map((r) => r.id))); - else setSelectedIds(new Set()); - }} - /> - - 공정코드 - 공정명 - 공정유형 - 표준시간(분) - 작업인원 - 사용여부 - - - - {processGridData.map((row) => ( - { - const proc = processes.find((p) => p.id === row.id); - setSelectedProcess(proc || null); - }} - > - e.stopPropagation()}> - { - setSelectedIds((prev) => { - const next = new Set(prev); - if (checked) next.add(row.id); - else next.delete(row.id); - return next; - }); - }} - /> - - {row.process_code} - {row.process_name} - {row.process_type_display} - {row.standard_time} - {row.worker_count} - {row.use_yn_display} - - ))} - -
- )} + {val} }, + { key: "process_name", label: "공정명" }, + { key: "process_type_display", label: "공정유형", width: "w-[120px]" }, + { key: "standard_time", label: "표준시간(분)", width: "w-[110px]", align: "right" as const }, + { key: "worker_count", label: "작업인원", width: "w-[90px]", align: "right" as const }, + { key: "use_yn_display", label: "사용여부", width: "w-[90px]", align: "center" as const }, + ] as EDataTableColumn[]} + data={processGridData} + rowKey={(row) => row.id} + loading={listBusy} + emptyMessage="조회된 공정이 없어요" + selectedId={selectedProcess?.id ?? null} + onSelect={(id) => { + const proc = processes.find((p) => p.id === id); + setSelectedProcess(proc || null); + }} + onRowClick={(row) => { + const proc = processes.find((p) => p.id === row.id); + setSelectedProcess(proc || null); + }} + showCheckbox + checkedIds={Array.from(selectedIds)} + onCheckedChange={(ids) => setSelectedIds(new Set(ids))} + showPagination={false} + draggableColumns={false} + />
diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx index b70ab356..08ace7ec 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx @@ -23,6 +23,7 @@ import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const GRID_COLUMNS = [ { key: "work_instruction_no", label: "작업지시번호" }, @@ -445,104 +446,74 @@ export default function WorkInstructionPage() { {/* 테이블 */} -
- - - - {ts.isVisible("work_instruction_no") && 작업지시번호} - {ts.isVisible("status") && 상태} - {ts.isVisible("progress") && 진행현황} - {ts.isVisible("item_name") && 품목명} - {ts.isVisible("spec") && 규격} - {ts.isVisible("qty") && 수량} - {ts.isVisible("equipment") && 설비} - {ts.isVisible("routing") && 라우팅} - {ts.isVisible("work_team") && 작업조} - {ts.isVisible("worker") && 작업자} - {ts.isVisible("start_date") && 시작일} - {ts.isVisible("end_date") && 완료일} - {ts.isVisible("actions") && 작업} - - - - {loading ? ( - - ) : orders.length === 0 ? ( - - -
-
- -
-

등록된 작업지시가 없어요

-

새로운 작업지시를 등록해주세요

-
-
-
- ) : orders.map((o, rowIdx) => { - const pct = getProgress(o); - const pLabel = getProgressLabel(o); - const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"]; - const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"]; - const isFirstOfGroup = Number(o.detail_seq) === 1; + {getDisplayNo(row)} }, + { key: "status", label: "상태", width: "w-[70px]", align: "center", render: (v) => { + const sBadge = STATUS_BADGE[v] || STATUS_BADGE["일반"]; + return {sBadge.label}; + }}, + { key: "progress", label: "진행현황", width: "w-[100px]", align: "center", sortable: false, filterable: false, render: (_v, row) => { + const isFirstOfGroup = Number(row.detail_seq) === 1; + if (!isFirstOfGroup) return ; + const pct = getProgress(row); + const pLabel = getProgressLabel(row); + const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"]; + return ( +
+ {pBadge.label} +
+
= 100 ? "bg-success" : pct > 0 ? "bg-primary" : "bg-muted-foreground/30")} style={{ width: `${pct}%` }} /> +
+ {pct}% +
+ ); + }}, + { key: "item_name", label: "품목명", render: (_v, row) => row.item_name || row.item_number || "-" }, + { key: "item_spec", label: "규격", width: "w-[100px]" }, + { key: "detail_qty", label: "수량", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "equipment_name", label: "설비", width: "w-[120px]", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, + { key: "routing", label: "라우팅", width: "w-[120px]", sortable: false, filterable: false, render: (_v, row) => { + const isFirstOfGroup = Number(row.detail_seq) === 1; + if (!isFirstOfGroup) return ""; + if (row.routing_version_id) { return ( - - {ts.isVisible("work_instruction_no") && {getDisplayNo(o)}} - {ts.isVisible("status") && {sBadge.label}} - {ts.isVisible("progress") && - {isFirstOfGroup ? ( -
- {pBadge.label} -
-
= 100 ? "bg-success" : pct > 0 ? "bg-primary" : "bg-muted-foreground/30")} style={{ width: `${pct}%` }} /> -
- {pct}% -
- ) : } - } - {ts.isVisible("item_name") && {o.item_name || o.item_number || "-"}} - {ts.isVisible("spec") && {o.item_spec || "-"}} - {ts.isVisible("qty") && {Number(o.detail_qty || 0).toLocaleString()}} - {ts.isVisible("equipment") && {isFirstOfGroup ? (o.equipment_name || "-") : ""}} - {ts.isVisible("routing") && - {isFirstOfGroup ? ( - o.routing_version_id ? ( - - ) : - - ) : ""} - } - {ts.isVisible("work_team") && {isFirstOfGroup ? (o.work_team || "-") : ""}} - {ts.isVisible("worker") && {isFirstOfGroup ? getWorkerName(o.worker) : ""}} - {ts.isVisible("start_date") && {isFirstOfGroup ? (o.start_date || "-") : ""}} - {ts.isVisible("end_date") && {isFirstOfGroup ? (o.end_date || "-") : ""}} - {ts.isVisible("actions") && - {isFirstOfGroup && ( -
- - -
- )} -
} - + ); - })} - -
-
+ } + return -; + }}, + { key: "work_team", label: "작업조", width: "w-[80px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, + { key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" }, + { key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, + { key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, + { key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => { + const isFirstOfGroup = Number(row.detail_seq) === 1; + if (!isFirstOfGroup) return null; + return ( +
+ + +
+ ); + }}, + ] as EDataTableColumn[]} + data={ts.groupData(orders)} + rowKey={(row) => `${row.wi_id}-${row.detail_id}`} + loading={loading} + emptyMessage="등록된 작업지시가 없어요" + showPagination + draggableColumns + columnOrderKey="c16-work-instruction" + /> diff --git a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index 0f0fbfa1..8e5074c3 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -26,6 +26,7 @@ import { toast } from "sonner"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const MASTER_TABLE = "purchase_order_mng"; const DETAIL_TABLE = "purchase_detail"; @@ -588,8 +589,6 @@ export default function PurchaseOrderPage() { toast.success("다운로드 완료"); }; - const allChecked = orders.length > 0 && checkedIds.length === orders.length; - const someChecked = checkedIds.length > 0 && checkedIds.length < orders.length; return (
@@ -638,90 +637,32 @@ export default function PurchaseOrderPage() {
{/* 데이터 테이블 */} -
- - - - - { - setCheckedIds(checked ? orders.map((o) => o.id) : []); - }} - /> - - {ts.isVisible("purchase_no") && 발주번호} - {ts.isVisible("order_date") && 발주일} - {ts.isVisible("supplier_name") && 공급업체} - {ts.isVisible("item_code") && 품번} - {ts.isVisible("item_name") && 품명} - {ts.isVisible("spec") && 규격} - {ts.isVisible("order_qty") && 발주수량} - {ts.isVisible("received_qty") && 입고수량} - {ts.isVisible("remain_qty") && 잔량} - {ts.isVisible("unit_price") && 단가} - {ts.isVisible("amount") && 금액} - {ts.isVisible("due_date") && 납기일} - {ts.isVisible("status") && 상태} - {ts.isVisible("memo") && 메모} - - - - {loading ? ( - - - - - - ) : orders.length === 0 ? ( - - - 등록된 발주가 없어요 - - - ) : orders.map((row) => ( - openEditModal(row.purchase_no)} - > - e.stopPropagation()}> - { - setCheckedIds((prev) => - checked ? [...prev, row.id] : prev.filter((id) => id !== row.id) - ); - }} - /> - - {ts.isVisible("purchase_no") && {row.purchase_no}} - {ts.isVisible("order_date") && {row.order_date}} - {ts.isVisible("supplier_name") && {row.supplier_name}} - {ts.isVisible("item_code") && {row.item_code}} - {ts.isVisible("item_name") && {row.item_name}} - {ts.isVisible("spec") && {row.spec}} - {ts.isVisible("order_qty") && {row.order_qty ? Number(row.order_qty).toLocaleString() : ""}} - {ts.isVisible("received_qty") && {row.received_qty ? Number(row.received_qty).toLocaleString() : ""}} - {ts.isVisible("remain_qty") && {row.remain_qty ? Number(row.remain_qty).toLocaleString() : ""}} - {ts.isVisible("unit_price") && {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}} - {ts.isVisible("amount") && {row.amount ? Number(row.amount).toLocaleString() : ""}} - {ts.isVisible("due_date") && {row.due_date}} - {ts.isVisible("status") && ( - - {row.status && ( - - {row.status} - - )} - - )} - {ts.isVisible("memo") && {row.memo}} - - ))} - -
+
+ ({ + key: col.key, + label: col.label, + align: ["order_qty", "received_qty", "remain_qty", "unit_price", "amount"].includes(col.key) ? "right" : undefined, + formatNumber: ["order_qty", "received_qty", "remain_qty", "unit_price", "amount"].includes(col.key), + render: col.key === "status" + ? (val: any, row: any) => row.status ? ( + + {row.status} + + ) : null + : undefined, + }))} + data={ts.groupData(orders)} + loading={loading} + emptyMessage="등록된 발주가 없어요" + showCheckbox + checkedIds={checkedIds} + onCheckedChange={setCheckedIds} + onRowDoubleClick={(row) => openEditModal(row.purchase_no)} + showPagination + draggableColumns={false} + columnOrderKey="c16-purchase-order" + />
{/* 발주 등록/수정 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx index b730842a..c031bc9e 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -20,6 +20,7 @@ import { exportToExcel } from "@/lib/utils/excelExport"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const ITEM_TABLE = "item_info"; const MAPPING_TABLE = "supplier_item_mapping"; @@ -128,6 +129,25 @@ export default function PurchaseItemPage() { const isColVisible = (key: string) => ts.isVisible(key); const itemColSpan = 2 + ITEM_COLUMNS.filter((c) => isColVisible(c.key)).length; + const mainTableColumns = useMemo(() => { + const cols: EDataTableColumn[] = [ + { key: "item_number", label: "품번", width: "w-[110px]" }, + { key: "item_name", label: "품명" }, + ]; + if (isColVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" }); + if (isColVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" }); + if (isColVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true }); + if (isColVisible("status")) cols.push({ + key: "status", label: "상태", width: "w-[60px]", align: "center", + render: (v) => ( + {v || "-"} + ), + }); + return cols; + }, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps + // 우측: 공급업체 매핑 조회 useEffect(() => { if (!selectedItem?.item_number) { setSupplierItems([]); setSupplierCheckedIds([]); return; } @@ -380,50 +400,18 @@ export default function PurchaseItemPage() {
-
- - - - 품번 - 품명 - {isColVisible("size") && 규격} - {isColVisible("unit") && 단위} - {isColVisible("standard_price") && 기준단가} - {isColVisible("status") && 상태} - - - - {itemLoading ? ( - - ) : items.length === 0 ? ( - 등록된 구매품목이 없어요 - ) : items.map((item) => ( - setSelectedItemId(item.id)} - onDoubleClick={openEditItem} - > - {item.item_number} - {item.item_name} - {isColVisible("size") && {item.size || "-"}} - {isColVisible("unit") && {item.unit || "-"}} - {isColVisible("standard_price") && {item.standard_price ? Number(item.standard_price).toLocaleString() : "-"}} - {isColVisible("status") && ( - - {item.status || "-"} - - )} - - ))} - -
-
+ setSelectedItemId(id)} + onRowDoubleClick={() => openEditItem()} + showPagination={true} + draggableColumns={false} + columnOrderKey="c16-purchase-item-main" + /> diff --git a/frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx index 8c06cbb8..4eb3ed3f 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/supplier/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -20,6 +20,7 @@ import { exportToExcel } from "@/lib/utils/excelExport"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const SUPPLIER_TABLE = "supplier_mng"; const MAPPING_TABLE = "supplier_item_mapping"; @@ -101,6 +102,24 @@ export default function SupplierManagementPage() { const isColVisible = (key: string) => ts.isVisible(key); const supplierColSpan = 2 + SUPPLIER_COLUMNS.filter((c) => isColVisible(c.key)).length; + const mainTableColumns = useMemo(() => { + const cols: EDataTableColumn[] = [ + { key: "supplier_code", label: "공급업체코드", width: "w-[120px]" }, + { key: "supplier_name", label: "공급업체명" }, + ]; + if (isColVisible("contact_person")) cols.push({ key: "contact_person", label: "담당자", width: "w-[90px]", render: (v) => v || "-" }); + if (isColVisible("contact_phone")) cols.push({ key: "contact_phone", label: "연락처", width: "w-[120px]", render: (v) => v || "-" }); + if (isColVisible("status")) cols.push({ + key: "status", label: "상태", width: "w-[70px]", align: "center", + render: (v) => ( + {v || "-"} + ), + }); + return cols; + }, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps + // 우측: 품목 매핑 조회 useEffect(() => { if (!selectedSupplier?.supplier_code) { setMappingItems([]); setMappingCheckedIds([]); return; } @@ -369,48 +388,18 @@ export default function SupplierManagementPage() { -
- - - - 공급업체코드 - 공급업체명 - {isColVisible("contact_person") && 담당자} - {isColVisible("contact_phone") && 연락처} - {isColVisible("status") && 상태} - - - - {supplierLoading ? ( - - ) : suppliers.length === 0 ? ( - 등록된 공급업체가 없어요 - ) : suppliers.map((s) => ( - setSelectedSupplierId(s.id)} - onDoubleClick={openSupplierEdit} - > - {s.supplier_code} - {s.supplier_name} - {isColVisible("contact_person") && {s.contact_person || "-"}} - {isColVisible("contact_phone") && {s.contact_phone || "-"}} - {isColVisible("status") && ( - - {s.status || "-"} - - )} - - ))} - -
-
+ setSelectedSupplierId(id)} + onRowDoubleClick={() => openSupplierEdit()} + showPagination={true} + draggableColumns={false} + columnOrderKey="c16-supplier-main" + /> diff --git a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx index 41eab9c1..cacd9d02 100644 --- a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -39,6 +39,7 @@ import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; /* ───── 테이블명 ───── */ const INSPECTION_TABLE = "inspection_standard"; @@ -181,6 +182,16 @@ export default function InspectionManagementPage() { return opts.find((o) => o.code === code)?.label || code; }; + const inspTableColumns = useMemo(() => { + return ts.visibleColumns.map((col) => { + const base: EDataTableColumn = { key: col.key, label: col.label }; + if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) { + base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]); + } + return base; + }); + }, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps + /* ═══════════════════ 데이터 조회 ═══════════════════ */ // 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용 const MULTI_VALUE_COLUMNS = ["inspection_type"]; @@ -666,99 +677,19 @@ export default function InspectionManagementPage() { />
- - - - - 0 && inspChecked.length === inspections.length} - onCheckedChange={(v) => setInspChecked(v ? inspections.map((r) => r.id) : [])} - /> - - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {inspLoading ? ( - - - - - - ) : inspections.length === 0 ? ( - - - -

등록된 검사기준이 없어요

-
-
- ) : ( - inspections.map((row) => ( - - setInspChecked((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id], - ) - } - onDoubleClick={() => openInspEdit(row)} - > - e.stopPropagation()}> - - setInspChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id))) - } - /> - - {ts.visibleColumns.map((col) => { - if (col.key === "inspection_type") - return ( - - {getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)} - - ); - if (col.key === "inspection_method") - return ( - - {getCatLabel(INSPECTION_TABLE, "inspection_method", row.inspection_method)} - - ); - if (col.key === "judgment_criteria") - return ( - - {getCatLabel(INSPECTION_TABLE, "judgment_criteria", row.judgment_criteria)} - - ); - if (col.key === "unit") - return ( - {getCatLabel(INSPECTION_TABLE, "unit", row.unit)} - ); - if (col.key === "apply_type") - return ( - - {getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)} - - ); - return {row[col.key] ?? ""}; - })} - - )) - )} -
-
+ openInspEdit(row)} + showPagination={true} + draggableColumns={false} + columnOrderKey="c16-inspection-main" + />
diff --git a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx index dccae8da..d030846f 100644 --- a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx @@ -20,6 +20,7 @@ import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const TABLE_NAME = "item_inspection_info"; @@ -302,48 +303,29 @@ export default function ItemInspectionInfoPage() { />
-
- - - - - 0 && checkedIds.length === data.length} - onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])} - /> - - {ts.visibleColumns.map((col) => ( - {col.label} - ))} - - - - {loading ? ( - - ) : data.length === 0 ? ( -

등록된 품목검사정보가 없어요

- ) : data.map((row) => ( - setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} - onDoubleClick={() => openEdit(row)} - > - e.stopPropagation()}> - setCheckedIds(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> - - {ts.visibleColumns.map((col) => ( - - {col.key === "is_active" - ? {row.is_active ? "사용" : "미사용"} - : row[col.key] ?? ""} - - ))} - - ))} -
-
-
+ ({ + key: col.key, + label: col.label, + render: col.key === "is_active" + ? (val: any, row: any) => ( + + {row.is_active ? "사용" : "미사용"} + + ) + : undefined, + }))} + data={ts.groupData(data)} + loading={loading} + emptyMessage="등록된 품목검사정보가 없어요" + showCheckbox + checkedIds={checkedIds} + onCheckedChange={setCheckedIds} + onRowDoubleClick={(row) => openEdit(row)} + showPagination + draggableColumns={false} + columnOrderKey="c16-item-inspection" + />
diff --git a/frontend/app/(main)/COMPANY_16/sales/claim/page.tsx b/frontend/app/(main)/COMPANY_16/sales/claim/page.tsx index 78b0d1ca..a2c89599 100644 --- a/frontend/app/(main)/COMPANY_16/sales/claim/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/claim/page.tsx @@ -63,6 +63,7 @@ import { Wrench, Settings2, } from "lucide-react"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; @@ -463,91 +464,38 @@ export default function ClaimManagementPage() { {/* 테이블 */} -
- - - - # - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {loading && data.length === 0 ? ( - - -
- - 불러오는 중... -
-
-
- ) : data.length === 0 ? ( - - -
- - 등록된 클레임이 없어요 -
-
-
- ) : ( - data.map((claim, idx) => ( - handleRowClick(claim.claim_no)} - onDoubleClick={() => openEditModal(claim.claim_no)} - > - - {idx + 1} - - {ts.visibleColumns.map((col) => { - if (col.key === "claim_type") { - return ( - - - {claim.claim_type} - - - ); - } - if (col.key === "claim_status") { - return ( - - - {claim.claim_status} - - - ); - } - if (col.key === "claim_content") { - return ( - - {claim.claim_content} - - ); - } - return ( - - {claim[col.key] ?? "-"} - - ); - })} - - )) - )} -
-
-
+ => ({ + key: col.key, + label: col.label, + align: col.key === "claim_type" || col.key === "claim_status" ? "center" : undefined, + render: col.key === "claim_type" + ? (val: any) => ( + + {val} + + ) + : col.key === "claim_status" + ? (val: any) => ( + + {val} + + ) + : undefined, + }))} + data={ts.groupData(data)} + loading={loading} + emptyMessage="등록된 클레임이 없어요" + rowKey={(row) => String(row.id)} + selectedId={selectedClaimNo ? String(data.find(c => c.claim_no === selectedClaimNo)?.id ?? "") : null} + onSelect={(id) => { + const claim = data.find(c => String(c.id) === id); + handleRowClick(claim?.claim_no ?? ""); + }} + onRowDoubleClick={(row) => openEditModal(row.claim_no)} + showRowNumber + draggableColumns={false} + /> diff --git a/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_16/sales/customer/page.tsx index f888bddf..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,28 +41,51 @@ 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"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const CUSTOMER_TABLE = "customer_mng"; const MAPPING_TABLE = "customer_item_mapping"; const PRICE_TABLE = "customer_item_prices"; const DELIVERY_TABLE = "delivery_destination"; +const CONTACT_TABLE = "customer_contact"; const CUSTOMER_GRID_COLUMNS = [ { key: "customer_code", label: "거래처코드" }, { key: "customer_name", label: "거래처명" }, - { key: "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([]); @@ -65,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); @@ -72,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); // 품목 편집 데이터 (더블클릭 시 상세 입력 모달 재활용) @@ -109,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); @@ -151,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 }) @@ -176,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) { @@ -199,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); // 선택된 거래처의 품목 단가 조회 @@ -255,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 || ""), @@ -280,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 { @@ -292,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 { @@ -315,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 = () => { @@ -329,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); }; @@ -344,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; } @@ -355,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 { @@ -382,7 +789,6 @@ export default function CustomerManagementPage() { const handleCustomerDelete = async () => { if (!selectedCustomerId) return; const ok = await confirm("거래처를 삭제하시겠습니까?", { - description: "관련된 품목 매핑, 단가, 납품처 정보도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제", }); if (!ok) return; @@ -453,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, @@ -487,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; }), @@ -586,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, @@ -626,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 { @@ -694,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; @@ -750,33 +1220,48 @@ 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); const customerColSpan = 1 + ["customer_code", "customer_name", "contact_person", "contact_phone", "division", "status"] .filter((k) => isColumnVisible(k)).length; + // EDataTable 컬럼 정의 (거래처 목록) + const customerColumns: EDataTableColumn[] = [ + ...(isColumnVisible("customer_code") ? [{ key: "customer_code", label: "거래처코드", width: "w-[120px]" }] : []), + ...(isColumnVisible("customer_name") ? [{ key: "customer_name", label: "거래처명", minWidth: "min-w-[140px]" }] : []), + ...(isColumnVisible("division") ? [{ + key: "division", + label: "거래유형", + width: "w-[80px]", + render: (val: any) => + val ? ( + + {val} + + ) : 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: "상태", + width: "w-[70px]", + render: (val: any) => + val ? ( + + {val} + + ) : null, + }] : []), + ]; + // 엑셀 다운로드 const handleExcelDownload = async () => { if (customers.length === 0) return; @@ -890,14 +1375,18 @@ export default function CustomerManagementPage() {
{/* 패널 헤더 */} -
+
거래처 목록 {customerCount}건
-
+
+ @@ -914,73 +1403,21 @@ export default function CustomerManagementPage() {
{/* 거래처 테이블 */} -
- - - - No - {isColumnVisible("customer_code") && 거래처코드} - {isColumnVisible("customer_name") && 거래처명} - {isColumnVisible("contact_person") && 대표자} - {isColumnVisible("contact_phone") && 연락처} - {isColumnVisible("division") && 유형} - {isColumnVisible("status") && 상태} - - - - {customerLoading ? ( - - - - - - ) : customers.length === 0 ? ( - - - 등록된 거래처가 없어요 - - - ) : customers.map((c, idx) => ( - setSelectedCustomerId(c.id)} - onDoubleClick={() => { setSelectedCustomerId(c.id); openCustomerEdit(); }} - > - {idx + 1} - {isColumnVisible("customer_code") && {c.customer_code}} - {isColumnVisible("customer_name") && {c.customer_name}} - {isColumnVisible("contact_person") && {c.contact_person}} - {isColumnVisible("contact_phone") && {c.contact_phone}} - {isColumnVisible("division") && ( - - {c.division && ( - - {c.division} - - )} - - )} - {isColumnVisible("status") && ( - - {c.status && ( - - {c.status} - - )} - - )} - - ))} - -
-
+ c.status !== "비활성"))} + rowKey={(row) => row.id} + loading={customerLoading} + emptyMessage="등록된 거래처가 없어요" + selectedId={selectedCustomerId} + onSelect={(id) => setSelectedCustomerId(id)} + onRowDoubleClick={(row) => { setSelectedCustomerId(row.id); openCustomerEdit(); }} + showRowNumber + showPagination + defaultPageSize={20} + draggableColumns={false} + columnOrderKey="c16-customer" + />
@@ -1000,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} + +
+
+
+
+
+ ); + })()} + + ); + })}
@@ -1128,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} @@ -1200,7 +1692,7 @@ export default function CustomerManagementPage() { {d.memo} {d.is_default && ( - 기본 + 메인 )} @@ -1217,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 && ( + + )} + - +
@@ -1374,7 +2344,7 @@ export default function CustomerManagementPage() {
- + - + 품목 상세정보 {editItemData ? "수정" : "입력"} — {selectedCustomer?.customer_name || ""} @@ -1450,7 +2420,7 @@ export default function CustomerManagementPage() { : "선택한 품목의 거래처 품번/품명과 기간별 단가를 설정합니다."} -
+
{selectedItemsForDetail.map((item, idx) => { const itemKey = item.item_number || item.id; const mappingRows = itemMappings[itemKey] || []; @@ -1464,10 +2434,10 @@ export default function CustomerManagementPage() {
{itemKey} | {item.size || ""} | {item.unit || ""}
-
+
{/* 좌: 거래처 품번/품명 */} -
-
+
+
거래처 품번/품명 관리 @@ -1475,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" + /> + + + ))} + + )}
{/* 우: 기간별 단가 */} -
-
+
+
기간별 단가 설정 @@ -1524,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="기준가" />
@@ -1593,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) => ( @@ -1603,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" />
@@ -1613,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} @@ -1623,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 || ""} + + )}
+
}
))}
@@ -1657,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: "규격" }, @@ -53,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(); @@ -72,6 +179,9 @@ export default function SalesOrderPage() { const [saving, setSaving] = useState(false); const [masterForm, setMasterForm] = useState>({}); const [detailRows, setDetailRows] = useState([]); + const [allowPriceEdit, setAllowPriceEdit] = useState(true); + const [expandedOrders, setExpandedOrders] = useState>(new Set()); + const [closingOrders, setClosingOrders] = useState>(new Set()); // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); @@ -104,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 () => { @@ -244,8 +358,10 @@ export default function SalesOrderPage() { ...row, part_name: row.part_name || item?.item_name || "", spec: row.spec || item?.size || "", + material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""), unit: resolveLabel("item_unit", rawUnit) || rawUnit, memo: row.memo || master?.memo || "", + _master: master || {}, }; }); @@ -260,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); @@ -323,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("삭제되었습니다."); @@ -506,12 +765,12 @@ export default function SalesOrderPage() { part_name: item.item_name, spec: item.size || "", material: getCategoryLabel("item_material", item.material) || item.material || "", + packing_material: "", unit: getCategoryLabel("item_unit", item.unit) || item.unit || "", - qty: "", - standard_price: item.standard_price || "", + qty: "1", + pack_qty: "0", unit_price: unitPrice, - amount: "", - currency_code: item.currency_code || "", + amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", due_date: "", }; }); @@ -560,13 +819,6 @@ export default function SalesOrderPage() { toast.success("다운로드 완료"); }; - // 전체 선택/해제 - const isAllChecked = orders.length > 0 && orders.every((o) => checkedIds.includes(o.id)); - const toggleAllChecked = () => { - if (isAllChecked) setCheckedIds([]); - else setCheckedIds(orders.map((o) => o.id).filter(Boolean)); - }; - return (
{/* 브레드크럼 */} @@ -632,45 +884,123 @@ 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); + }} + > + { + const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id)); + return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); + })()} + onCheckedChange={() => {}} /> - {ts.isVisible("order_no") && 수주번호} - {ts.isVisible("part_code") && 품번} - {ts.isVisible("part_name") && 품명} - {ts.isVisible("spec") && 규격} - {ts.isVisible("unit") && 단위} - {ts.isVisible("qty") && 수량} - {ts.isVisible("ship_qty") && 출하수량} - {ts.isVisible("balance_qty") && 잔량} - {ts.isVisible("unit_price") && 단가} - {ts.isVisible("amount") && 금액} - {ts.isVisible("currency_code") && 통화} - {ts.isVisible("due_date") && 납기일} - {ts.isVisible("memo") && 메모} + + {/* 수주번호 (별도 컬럼) */} + +
+
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 ? ( - + - ) : orders.length === 0 ? ( + ) : Object.keys(filteredOrderGroups).length === 0 ? ( - +
등록된 수주가 없어요 @@ -678,49 +1008,200 @@ export default function SalesOrderPage() { ) : ( - orders.map((row) => { - const isChecked = checkedIds.includes(row.id); + 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; return ( - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - )} - onDoubleClick={() => openEditModal(row.order_no)} - > - - - - {ts.isVisible("order_no") && {row.order_no}} - {ts.isVisible("part_code") && ( - - {row.part_code} + + {/* 마스터 행 — 마스터 테이블 필드만 표시 */} + { + if (expandedOrders.has(orderNo)) { + setClosingOrders((prev) => new Set(prev).add(orderNo)); + setTimeout(() => { + setExpandedOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; }); + setClosingOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; }); + }, 200); + } else { + setExpandedOrders((prev) => new Set(prev).add(orderNo)); + } + }} + onDoubleClick={() => openEditModal(orderNo)} + > + { + e.stopPropagation(); + setCheckedIds((prev) => { + if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id)); + return [...new Set([...prev, ...detailIds])]; + }); + }} + > + {}} + /> - )} - {ts.isVisible("part_name") && ( - - {row.part_name} + + {isExpanded + ? + : + } - )} - {ts.isVisible("spec") && {row.spec}} - {ts.isVisible("unit") && {row.unit}} - {ts.isVisible("qty") && {row.qty ? Number(row.qty).toLocaleString() : ""}} - {ts.isVisible("ship_qty") && {row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}} - {ts.isVisible("balance_qty") && {row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}} - {ts.isVisible("unit_price") && {row.unit_price ? Number(row.unit_price).toLocaleString() : ""}} - {ts.isVisible("amount") && {row.amount ? Number(row.amount).toLocaleString() : ""}} - {ts.isVisible("currency_code") && {row.currency_code}} - {ts.isVisible("due_date") && {row.due_date}} - {ts.isVisible("memo") && ( - - {row.memo} + {/* 수주번호 */} + + {orderNo} + ({group.details.length}) + {/* 거래처 (colSpan=2) */} + + + {master.partner_id ? (categoryOptions["partner_id"]?.find((o) => o.code === master.partner_id)?.label || master.partner_id) : ""} + + + {/* 단가방식 (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 || ""} + + + + {/* 디테일 서브 헤더 (펼쳤을 때만) */} + {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); + const isChecked = checkedIds.includes(row.id); + return ( + { + setCheckedIds((prev) => + prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] + ); + }} + onDoubleClick={() => openEditModal(row.order_no)} + > + { + 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} + {row.unit} + {row.qty ? Number(row.qty).toLocaleString() : ""} + {row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""} + {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 || ""} + + + ); + })} + ); }) )} @@ -731,7 +1212,7 @@ export default function SalesOrderPage() { {/* 수주 등록/수정 모달 */} - + {isEditMode ? "수주 수정" : "수주 등록"} @@ -805,6 +1286,12 @@ export default function SalesOrderPage() {
+
+ +
@@ -913,7 +1400,16 @@ export default function SalesOrderPage() {
- setMasterForm((p) => ({ ...p, currency: e.target.value }))} placeholder="KRW" className="h-9" /> +
@@ -958,21 +1454,22 @@ export default function SalesOrderPage() {
) : (
-
+
- # - 품번 - 품명 - 규격 - 단위 - 기준단가 - 수량 - 단가 - 금액 - 통화 - 납기일 - + No + 품번 + 품명 + 규격 + 재질 + 포장재 + 단위 + 수량 + 포장수량 + 단가 + 금액 + 납기일 + 분할/삭제 @@ -986,33 +1483,53 @@ export default function SalesOrderPage() { {row.part_name} {row.spec} - {row.unit} - - {row.standard_price ? Number(row.standard_price).toLocaleString() : ""} + {row.material} + + updateDetailRow(idx, "packing_material", e.target.value)} + placeholder="포장재" + className="h-8 text-xs" + /> + + + updateDetailRow(idx, "qty", parseNumber(e.target.value))} - className="h-8 text-xs text-right font-mono" + type="number" + min="1" + value={row.qty || "1"} + onChange={(e) => updateDetailRow(idx, "qty", e.target.value)} + className="h-8 text-xs text-right font-mono w-16" + /> + + + updateDetailRow(idx, "pack_qty", e.target.value)} + className="h-8 text-xs text-right font-mono w-16" /> updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} - className="h-8 text-xs text-right font-mono" + readOnly={!allowPriceEdit} + className={cn("h-8 text-xs text-right font-mono w-20", !allowPriceEdit && "bg-muted cursor-not-allowed")} /> - {row.amount ? Number(row.amount).toLocaleString() : ""} - - - updateDetailRow(idx, "currency_code", e.target.value)} - className="h-8 text-xs" - /> + {row.amount ? Number(row.amount).toLocaleString() : "0"} - - + +
+ + +
))} diff --git a/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx index 1ccf6d2c..3c510588 100644 --- a/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx @@ -29,6 +29,7 @@ import { exportToExcel } from "@/lib/utils/excelExport"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const ITEM_TABLE = "item_info"; const MAPPING_TABLE = "customer_item_mapping"; @@ -605,6 +606,18 @@ export default function SalesItemPage() { toast.success("다운로드 완료"); }; + // EDataTable 컬럼 정의 (판매품목) + const itemColumns: EDataTableColumn[] = [ + { key: "item_number", label: "품번", width: "w-[110px]" }, + { key: "item_name", label: "품명", minWidth: "min-w-[130px]" }, + { key: "size", label: "규격", width: "w-[80px]" }, + { key: "unit", label: "단위", width: "w-[60px]" }, + { key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true }, + { key: "currency_code", label: "통화", width: "w-[50px]" }, + { key: "status", label: "상태", width: "w-[60px]" }, + ]; + return (
@@ -649,58 +662,20 @@ export default function SalesItemPage() {
{/* 테이블 영역 */} -
- {itemLoading ? ( -
- -
- ) : items.length === 0 ? ( -
- 등록된 판매품목이 없어요 -
- ) : ( -
- - - # - 품번 - 품명 - 규격 - 단위 - 기준단가 - 판매가격 - 통화 - 상태 - - - - {items.map((item, idx) => ( - setSelectedItemId(item.id)} - onDoubleClick={() => openEditItem()} - > - {idx + 1} - {item.item_number} - {item.item_name} - {item.size} - {item.unit} - {formatNum(item.standard_price)} - {formatNum(item.selling_price)} - {item.currency_code} - {item.status} - - ))} - -
- )} -
+ row.id} + loading={itemLoading} + emptyMessage="등록된 판매품목이 없어요" + selectedId={selectedItemId} + onSelect={(id) => setSelectedItemId(id)} + onRowDoubleClick={() => openEditItem()} + showRowNumber + showPagination={false} + draggableColumns={false} + columnOrderKey="c16-sales-item" + /> diff --git a/frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx index 86ab3f0d..4ab5a9ad 100644 --- a/frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/shipping-order/page.tsx @@ -25,6 +25,7 @@ import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const GRID_COLUMNS = [ { key: "instruction_no", label: "출하지시번호" }, @@ -201,10 +202,6 @@ export default function ShippingOrderPage() { } }, [isModalOpen, dataSource]); - const handleCheckAll = (checked: boolean) => { - setCheckedIds(checked ? orders.map((o: any) => o.id) : []); - }; - const handleDeleteSelected = async () => { if (checkedIds.length === 0) return; if (!confirm(`선택한 ${checkedIds.length}개의 출하지시를 삭제하시겠습니까?`)) return; @@ -392,6 +389,70 @@ export default function ShippingOrderPage() { const formatDate = (d: string) => d ? d.split("T")[0] : "-"; + // 출하지시 데이터를 플랫한 행 목록으로 변환 (EDataTable용) + const flattenedOrders = useMemo(() => { + const rows: any[] = []; + for (const order of orders) { + const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : []; + if (items.length === 0) { + rows.push({ + _rowId: String(order.id), + _orderId: order.id, + _order: order, + instruction_no: order.instruction_no, + ship_date: formatDate(order.instruction_date), + customer_name: order.customer_name || "-", + transport_company: order.carrier_name || "-", + vehicle_no: order.vehicle_no || "-", + driver_name: order.driver_name || "-", + status: order.status, + item_code: "-", + item_name: "-", + qty: 0, + source_type: "-", + remark: order.memo || "-", + }); + } else { + items.forEach((item: any, idx: number) => { + rows.push({ + _rowId: `${order.id}-${item.id}`, + _orderId: order.id, + _order: order, + instruction_no: idx === 0 ? order.instruction_no : "", + ship_date: idx === 0 ? formatDate(order.instruction_date) : "", + customer_name: idx === 0 ? (order.customer_name || "-") : "", + transport_company: idx === 0 ? (order.carrier_name || "-") : "", + vehicle_no: idx === 0 ? (order.vehicle_no || "-") : "", + driver_name: idx === 0 ? (order.driver_name || "-") : "", + status: idx === 0 ? order.status : "", + item_code: item.item_code || "", + item_name: item.item_name || "", + qty: Number(item.order_qty || 0), + source_type: item.source_type || "", + remark: idx === 0 ? (order.memo || "-") : "", + }); + }); + } + } + return rows; + }, [orders]); + + // checkedIds를 order.id 기준으로 관리하므로 _orderId로 매핑 + const flatCheckedRowIds = useMemo(() => { + return flattenedOrders + .filter((r) => checkedIds.includes(r._orderId)) + .map((r) => r._rowId); + }, [flattenedOrders, checkedIds]); + + const handleFlatCheckedChange = useCallback((rowIds: string[]) => { + const orderIds = new Set(); + for (const rowId of rowIds) { + const row = flattenedOrders.find((r) => r._rowId === rowId); + if (row) orderIds.add(row._orderId); + } + setCheckedIds(Array.from(orderIds)); + }, [flattenedOrders]); + const dataSourceTitle: Record = { shipmentPlan: "출하계획 목록", salesOrder: "수주정보 목록", @@ -454,138 +515,42 @@ export default function ShippingOrderPage() { {/* 메인 테이블 */}
-
- {loading ? ( -
- -
- ) : ( - - - - - 0 && checkedIds.length === orders.length} - onCheckedChange={handleCheckAll} - /> - - {ts.isVisible("instruction_no") && 출하지시번호} - {ts.isVisible("ship_date") && 출하일자} - {ts.isVisible("customer_name") && 거래처명} - {ts.isVisible("transport_company") && 운송업체} - {ts.isVisible("vehicle_no") && 차량번호} - {ts.isVisible("driver_name") && 기사명} - {ts.isVisible("status") && 상태} - {ts.isVisible("item_code") && 품번} - {ts.isVisible("item_name") && 품명} - {ts.isVisible("qty") && 수량} - {ts.isVisible("source_type") && 소스} - {ts.isVisible("remark") && 비고} - - - - {orders.length === 0 ? ( - - -
-
- -
-

등록된 출하지시가 없어요

-

출하지시 등록 버튼으로 등록해주세요

-
-
-
- ) : ( - orders.map((order: any) => { - const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : []; - if (items.length === 0) { - return ( - setSelectedOrderId(order.id)} - onDoubleClick={() => openModal(order)} - > - e.stopPropagation()}> - { - if (c) setCheckedIds(p => [...p, order.id]); - else setCheckedIds(p => p.filter(i => i !== order.id)); - }} - /> - - {ts.isVisible("instruction_no") && {order.instruction_no}} - {ts.isVisible("ship_date") && {formatDate(order.instruction_date)}} - {ts.isVisible("customer_name") && {order.customer_name || "-"}} - {ts.isVisible("transport_company") && {order.carrier_name || "-"}} - {ts.isVisible("vehicle_no") && {order.vehicle_no || "-"}} - {ts.isVisible("driver_name") && {order.driver_name || "-"}} - {ts.isVisible("status") && - - {getStatusLabel(order.status)} - - } - {ts.isVisible("item_code") && -} - {ts.isVisible("item_name") && -} - {ts.isVisible("qty") && 0} - {ts.isVisible("source_type") && -} - {ts.isVisible("remark") && {order.memo || "-"}} - - ); - } - return items.map((item: any, itemIdx: number) => ( - setSelectedOrderId(order.id)} - onDoubleClick={() => openModal(order)} - > - e.stopPropagation()}> - {itemIdx === 0 && ( - { - if (c) setCheckedIds(p => [...p, order.id]); - else setCheckedIds(p => p.filter(i => i !== order.id)); - }} - /> - )} - - {ts.isVisible("instruction_no") && {itemIdx === 0 ? order.instruction_no : ""}} - {ts.isVisible("ship_date") && {itemIdx === 0 ? formatDate(order.instruction_date) : ""}} - {ts.isVisible("customer_name") && {itemIdx === 0 ? (order.customer_name || "-") : ""}} - {ts.isVisible("transport_company") && {itemIdx === 0 ? (order.carrier_name || "-") : ""}} - {ts.isVisible("vehicle_no") && {itemIdx === 0 ? (order.vehicle_no || "-") : ""}} - {ts.isVisible("driver_name") && {itemIdx === 0 ? (order.driver_name || "-") : ""}} - {ts.isVisible("status") && - {itemIdx === 0 && ( - - {getStatusLabel(order.status)} - - )} - } - {ts.isVisible("item_code") && {item.item_code}} - {ts.isVisible("item_name") && {item.item_name}} - {ts.isVisible("qty") && {Number(item.order_qty || 0).toLocaleString()}} - {ts.isVisible("source_type") && - {(() => { - const b = getSourceBadge(item.source_type || ""); - return {b.label}; - })()} - } - {ts.isVisible("remark") && - {itemIdx === 0 ? (order.memo || "-") : ""} - } - - )); - }) - )} -
-
- )} -
+ ({ + key: col.key, + label: col.label, + align: col.key === "qty" ? "right" : col.key === "status" || col.key === "source_type" || col.key === "ship_date" ? "center" : undefined, + formatNumber: col.key === "qty", + sortable: false, + filterable: false, + render: col.key === "status" + ? (val: any) => val ? ( + + {getStatusLabel(val)} + + ) : null + : col.key === "source_type" + ? (val: any) => { + if (!val || val === "-") return -; + const b = getSourceBadge(val); + return {b.label}; + } + : undefined, + }))} + data={ts.groupData(flattenedOrders)} + rowKey={(row) => row._rowId} + loading={loading} + emptyMessage="등록된 출하지시가 없어요" + showCheckbox + checkedIds={flatCheckedRowIds} + onCheckedChange={handleFlatCheckedChange} + selectedId={selectedOrderId != null ? String(selectedOrderId) : null} + onRowClick={(row) => setSelectedOrderId(row._orderId)} + onRowDoubleClick={(row) => openModal(row._order)} + showPagination + draggableColumns={false} + columnOrderKey="c16-shipping-order" + />
{/* 등록/수정 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx index 1a47e986..747ac23d 100644 --- a/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/shipping-plan/page.tsx @@ -18,6 +18,7 @@ import { import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const GRID_COLUMNS = [ { key: "order_no", label: "수주번호" }, @@ -114,10 +115,11 @@ export default function ShippingPlanPage() { const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]); const groupedData = useMemo(() => { - const orderMap = new Map(); + const grouped = ts.groupData(data); + const orderMap = new Map(); const orderKeys: string[] = []; - data.forEach(plan => { - const key = plan.order_no || `_no_order_${plan.id}`; + grouped.forEach(plan => { + const key = (plan as any)._isGroupSummary ? `_summary_${orderKeys.length}` : (plan.order_no || `_no_order_${plan.id}`); if (!orderMap.has(key)) { orderMap.set(key, []); orderKeys.push(key); @@ -128,7 +130,7 @@ export default function ShippingPlanPage() { orderNo: key, plans: orderMap.get(key)!, })); - }, [data]); + }, [data, ts.groupData]); const handleRowClick = (plan: ShipmentPlanListItem) => { if (isDetailChanged && selectedId !== plan.id) { @@ -233,91 +235,36 @@ export default function ShippingPlanPage() { {/* 테이블 */}
- - - - - 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length} - onCheckedChange={handleCheckAll} - /> - - {ts.isVisible("order_no") && 수주번호} - {ts.isVisible("due_date") && 납기일} - {ts.isVisible("customer_name") && 거래처} - {ts.isVisible("part_code") && 품목코드} - {ts.isVisible("part_name") && 품목명} - {ts.isVisible("order_qty") && 수주수량} - {ts.isVisible("plan_qty") && 계획수량} - {ts.isVisible("plan_date") && 계획일} - {ts.isVisible("status") && 상태} - - - - {groupedData.length === 0 ? ( - - -
-
- -
-

출하계획이 없어요

-

조건을 변경해서 다시 조회해주세요

-
-
-
- ) : ( - groupedData.map(group => - group.plans.map((plan, planIdx) => ( - handleRowClick(plan)} - > - e.stopPropagation()}> - {planIdx === 0 && ( - checkedIds.includes(p.id))} - onCheckedChange={(c) => { - if (c) { - setCheckedIds(prev => [...new Set([...prev, ...group.plans.filter(p => p.status !== "CANCELLED").map(p => p.id)])]); - } else { - setCheckedIds(prev => prev.filter(id => !group.plans.some(p => p.id === id))); - } - }} - /> - )} - - {ts.isVisible("order_no") && - {planIdx === 0 ? (plan.order_no || "-") : ""} - } - {ts.isVisible("due_date") && - {planIdx === 0 ? formatDate(plan.due_date) : ""} - } - {ts.isVisible("customer_name") && - {planIdx === 0 ? (plan.customer_name || "-") : ""} - } - {ts.isVisible("part_code") && {plan.part_code || "-"}} - {ts.isVisible("part_name") && {plan.part_name || "-"}} - {ts.isVisible("order_qty") && {formatNumber(plan.order_qty)}} - {ts.isVisible("plan_qty") && {formatNumber(plan.plan_qty)}} - {ts.isVisible("plan_date") && {formatDate(plan.plan_date)}} - {ts.isVisible("status") && - - {getStatusLabel(plan.status)} - - } - - )) - ) - )} -
-
+ {val || "-"} }, + { key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => {formatDate(val)} }, + { key: "customer_name", label: "거래처", render: (val: any) => {val || "-"} }, + { key: "part_code", label: "품목코드", render: (val: any) => {val || "-"} }, + { key: "part_name", label: "품목명", render: (val: any) => {val || "-"} }, + { key: "order_qty", label: "수주수량", align: "right" as const, formatNumber: true }, + { key: "plan_qty", label: "계획수량", align: "right" as const, render: (val: any) => {formatNumber(val)} }, + { key: "plan_date", label: "계획일", align: "center" as const, render: (val: any) => {formatDate(val)} }, + { key: "status", label: "상태", align: "center" as const, render: (val: any) => {getStatusLabel(val)} }, + ] as EDataTableColumn[]} + data={data} + rowKey={(row) => String(row.id)} + loading={loading} + emptyMessage="출하계획이 없어요" + selectedId={selectedId !== null ? String(selectedId) : null} + onSelect={(id) => { + if (id) { + const plan = data.find(p => String(p.id) === id); + if (plan) handleRowClick(plan); + } + }} + onRowClick={(row) => handleRowClick(row)} + showCheckbox + checkedIds={checkedIds.map(String)} + onCheckedChange={(ids) => setCheckedIds(ids.map(Number))} + showPagination={false} + draggableColumns={false} + />
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 3fe6de24..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 — 행 구분 */ -[data-slot="table-body"] [data-slot="table-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"]: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); } @@ -1080,3 +1080,63 @@ body span.messenger-time { [data-slot="table-body"] [data-slot="table-row"]:hover { background-color: hsl(var(--accent)) !important; } + +/* ===== 트리 테이블 마스터/디테일 구분 ===== */ +/* 마스터 행 — 좌측 primary 바 */ +.tree-master-row { + border-left: 3px solid hsl(var(--primary) / 0.5) !important; +} +.tree-master-row:hover { + background-color: hsl(var(--accent)) !important; +} + +/* 디테일 행 — 펼치기 애니메이션 */ +.tree-detail-row { + border-left: 3px solid hsl(var(--border)) !important; + animation: treeSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} +.tree-detail-row:hover { + background-color: hsl(var(--accent)) !important; +} + +@keyframes treeSlideIn { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 접기 애니메이션 */ +.tree-detail-row-closing { + border-left: 3px solid hsl(var(--border)) !important; + animation: treeSlideOut 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +@keyframes treeSlideOut { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-4px); max-height: 0; } +} + +/* ㄴ자 트리 경계선 */ +.tree-connector { + position: absolute; + left: 50%; + top: 0; + width: 12px; + height: 50%; + border-left: 1.5px solid hsl(var(--muted-foreground) / 0.3); + border-bottom: 1.5px solid hsl(var(--muted-foreground) / 0.3); + border-bottom-left-radius: 4px; +} +/* 마지막 디테일이 아닌 행 — 세로선 아래로 연장 */ +.tree-connector[data-last="false"]::after { + content: ''; + position: absolute; + left: -1.5px; + top: 50%; + bottom: -100%; + width: 0; + border-left: 1.5px solid hsl(var(--muted-foreground) / 0.3); +} +/* 마지막 디테일 행 — 세로선 중간까지만 */ +.tree-connector[data-last="true"] { + height: 50%; +} diff --git a/frontend/components/common/DynamicSearchFilter.tsx b/frontend/components/common/DynamicSearchFilter.tsx index 7e3316d2..28a57567 100644 --- a/frontend/components/common/DynamicSearchFilter.tsx +++ b/frontend/components/common/DynamicSearchFilter.tsx @@ -147,7 +147,10 @@ export function DynamicSearchFilter({ } setAllColumns(merged); - setActiveFilters(merged.filter((c) => c.enabled)); + // externalFilterConfig가 있으면 외부 설정이 activeFilters를 관리하므로 건드리지 않음 + if (!externalFilterConfig) { + setActiveFilters(merged.filter((c) => c.enabled)); + } // 저장된 필터 값 복원 const savedValues = localStorage.getItem(STORAGE_KEY_VALUES); diff --git a/frontend/components/common/EDataTable.tsx b/frontend/components/common/EDataTable.tsx new file mode 100644 index 00000000..526d9117 --- /dev/null +++ b/frontend/components/common/EDataTable.tsx @@ -0,0 +1,821 @@ +"use client"; + +/** + * EDataTable — 직접 구현 페이지용 공통 데이터 테이블 컴포넌트 + * + * 프리셋 디자인 규격(Type A~F) 기반, shadcn/ui 위에 구축. + * 기능: 정렬, 헤더 필터, 컬럼 드래그 이동, 인라인 편집, 체크박스, 페이지네이션 + */ + +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { + DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { + Filter, Check, Search, X, Loader2, Inbox, GripVertical, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; + +// ─── 타입 ─── + +export interface EDataTableColumn { + key: string; + label: string; + width?: string; + minWidth?: string; + align?: "left" | "center" | "right"; + sortable?: boolean; + filterable?: boolean; + editable?: boolean; + inputType?: "text" | "number" | "date" | "select"; + selectOptions?: { value: string; label: string }[]; + formatNumber?: boolean; + truncate?: boolean; + render?: (value: any, row: T, rowIndex: number) => React.ReactNode; +} + +export interface SortState { + key: string; + direction: "asc" | "desc"; +} + +export interface EDataTableProps = any> { + columns: EDataTableColumn[]; + data: T[]; + rowKey?: (row: T) => string; + + loading?: boolean; + emptyMessage?: string; + emptyIcon?: React.ReactNode; + + selectedId?: string | null; + onSelect?: (id: string | null) => void; + + showCheckbox?: boolean; + checkedIds?: string[]; + onCheckedChange?: (ids: string[]) => void; + + onRowClick?: (row: T, index: number) => void; + onRowDoubleClick?: (row: T, index: number) => void; + + onCellEdit?: (rowId: string, columnKey: string, newValue: any, row: T) => void; + tableName?: string; + + sort?: SortState | null; + onSortChange?: (sort: SortState | null) => void; + + draggableColumns?: boolean; + onColumnOrderChange?: (columns: EDataTableColumn[]) => void; + columnOrderKey?: string; + + showRowNumber?: boolean; + showPagination?: boolean; + defaultPageSize?: number; + + className?: string; +} + +// ─── 유틸 ─── + +const fmtNum = (val: any) => { + if (val == null || val === "") return ""; + const n = Number(String(val).replace(/,/g, "")); + if (isNaN(n)) return String(val); + return n.toLocaleString(); +}; + +const getRowId = (row: any, rowKey?: (row: any) => string) => { + if (rowKey) return rowKey(row); + return row.id ?? row._id ?? ""; +}; + +// ─── SortableHeaderCell ─── + +function SortableHeaderCell({ + col, sortKey, sortDir, onSort, + headerFilterValues, uniqueValues, onToggleFilter, onClearFilter, + draggable, +}: { + col: EDataTableColumn; + sortKey: string | null; + sortDir: "asc" | "desc"; + onSort: (key: string) => void; + headerFilterValues: Set; + uniqueValues: string[]; + onToggleFilter: (colKey: string, value: string) => void; + onClearFilter: (colKey: string) => void; + draggable: boolean; +}) { + const [filterSearch, setFilterSearch] = useState(""); + const { + attributes, listeners, setNodeRef, transform, transition, isDragging, + } = useSortable({ id: col.key, disabled: !draggable }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const isSorted = sortKey === col.key; + const hasFilter = headerFilterValues.size > 0; + const filteredUniqueValues = uniqueValues.filter( + (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()) + ); + + return ( + +
+ {/* 드래그 핸들 */} + {draggable && ( +
+ +
+ )} + + {/* 컬럼 라벨 + 정렬 */} +
{ + e.stopPropagation(); + if (col.sortable !== false) onSort(col.key); + }} + > + {col.label} + {isSorted && ( + sortDir === "asc" + ? + : + )} +
+ + {/* 필터 아이콘 + Popover */} + {col.filterable !== false && uniqueValues.length > 0 && ( + + + + + e.stopPropagation()}> +
+
+ 필터: {col.label} + {hasFilter && ( + + )} +
+
+ + setFilterSearch(e.target.value)} + placeholder="검색..." + className="h-7 text-xs pl-7" + /> +
+
+ {filteredUniqueValues.slice(0, 100).map((val) => { + const isSelected = headerFilterValues.has(val); + return ( +
onToggleFilter(col.key, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {filteredUniqueValues.length > 100 && ( +
+ ...외 {filteredUniqueValues.length - 100}개 +
+ )} +
+
+
+
+ )} +
+
+ ); +} + +// ─── EDataTable ─── + +export function EDataTable = any>({ + columns: initialColumns, + data, + rowKey, + loading = false, + emptyMessage = "데이터가 없어요", + emptyIcon, + selectedId, + onSelect, + showCheckbox = false, + checkedIds = [], + onCheckedChange, + onRowClick, + onRowDoubleClick, + onCellEdit, + tableName, + sort: externalSort, + onSortChange, + draggableColumns = true, + onColumnOrderChange, + columnOrderKey, + showRowNumber = false, + showPagination = true, + defaultPageSize = 50, + className, +}: EDataTableProps) { + const [columns, setColumns] = useState(initialColumns); + useEffect(() => { setColumns(initialColumns); }, [initialColumns]); + + // 정렬 + const [internalSort, setInternalSort] = useState(null); + const sortState = externalSort !== undefined ? externalSort : internalSort; + + // 헤더 필터 + const [headerFilters, setHeaderFilters] = useState>>({}); + + // 페이지네이션 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize); + const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize)); + + // 인라인 편집 + const [editingCell, setEditingCell] = useState<{ rowId: string; colKey: string } | null>(null); + const [editValue, setEditValue] = useState(""); + const editRef = useRef(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) + ); + + // localStorage에서 컬럼 순서 복원 + useEffect(() => { + if (!columnOrderKey) return; + const saved = localStorage.getItem(`edatatable_col_order_${columnOrderKey}`); + if (saved) { + try { + const order = JSON.parse(saved) as string[]; + const reordered = order + .map((key) => initialColumns.find((c) => c.key === key)) + .filter(Boolean) as EDataTableColumn[]; + const remaining = initialColumns.filter((c) => !order.includes(c.key)); + setColumns([...reordered, ...remaining]); + } catch { /* skip */ } + } + }, [columnOrderKey]); // eslint-disable-line react-hooks/exhaustive-deps + + // 컬럼별 고유값 + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + for (const col of columns) { + if (col.filterable === false) continue; + const values = new Set(); + data.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; + }, [data, columns]); + + // 드래그 완료 + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setColumns((prev) => { + const oldIndex = prev.findIndex((c) => c.key === active.id); + const newIndex = prev.findIndex((c) => c.key === over.id); + const next = arrayMove(prev, oldIndex, newIndex); + if (columnOrderKey) { + localStorage.setItem(`edatatable_col_order_${columnOrderKey}`, JSON.stringify(next.map((c) => c.key))); + } + onColumnOrderChange?.(next); + return next; + }); + }; + + // 정렬 + const handleSort = (key: string) => { + const newSort: SortState | null = sortState?.key === key + ? sortState.direction === "asc" + ? { key, direction: "desc" } + : null + : { key, direction: "asc" }; + + if (onSortChange) { + onSortChange(newSort); + } else { + setInternalSort(newSort); + } + }; + + // 헤더 필터 + 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 processedData = useMemo(() => { + let result = [...data]; + + // 헤더 필터 + if (Object.keys(headerFilters).length > 0) { + result = result.filter((row) => + Object.entries(headerFilters).every(([colKey, values]) => { + if (values.size === 0) return true; + const cellVal = row[colKey] != null ? String(row[colKey]) : ""; + return values.has(cellVal); + }) + ); + } + + // 정렬 (외부 정렬이 아닌 경우만) + if (sortState && !onSortChange) { + const { key, direction } = sortState; + result.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 result; + }, [data, headerFilters, sortState, onSortChange]); + + // 필터/데이터 변경 시 1페이지 리셋 + useEffect(() => { setCurrentPage(1); }, [data, headerFilters]); + + // 페이지네이션 + const totalItems = processedData.length; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + const safePage = Math.min(currentPage, totalPages); + + useEffect(() => { + if (currentPage > totalPages) setCurrentPage(totalPages); + }, [currentPage, totalPages]); + + const pageOffset = (safePage - 1) * pageSize; + const paginatedData = showPagination + ? processedData.slice(pageOffset, pageOffset + pageSize) + : processedData; + + const applyPageSize = () => { + const n = parseInt(pageSizeInput, 10); + if (!isNaN(n) && n >= 1) { + setPageSize(n); + setCurrentPage(1); + setPageSizeInput(String(n)); + } else { + setPageSizeInput(String(pageSize)); + } + }; + + const getPageNumbers = () => { + const delta = 2; + let start = Math.max(1, safePage - delta); + let end = Math.min(totalPages, safePage + delta); + if (end - start < delta * 2) { + if (start === 1) end = Math.min(totalPages, start + delta * 2); + else if (end === totalPages) start = Math.max(1, end - delta * 2); + } + const pages: (number | "...")[] = []; + if (start > 1) { pages.push(1); if (start > 2) pages.push("..."); } + for (let i = start; i <= end; i++) pages.push(i); + if (end < totalPages) { if (end < totalPages - 1) pages.push("..."); pages.push(totalPages); } + return pages; + }; + + // 인라인 편집 + const startEdit = (rowId: string, colKey: string, currentVal: any) => { + const col = columns.find((c) => c.key === colKey); + if (!col?.editable) return; + setEditingCell({ rowId, colKey }); + setEditValue(currentVal != null ? String(currentVal) : ""); + }; + + const saveEdit = useCallback(async () => { + if (!editingCell) return; + const { rowId, colKey } = editingCell; + const row = paginatedData.find((r) => getRowId(r, rowKey) === rowId); + if (!row) { setEditingCell(null); return; } + + const originalVal = String(row[colKey] ?? ""); + if (originalVal === editValue) { setEditingCell(null); return; } + + if (tableName && row.id) { + try { + await apiClient.put(`/table-management/tables/${tableName}/edit`, { + originalData: { id: row.id }, + updatedData: { [colKey]: editValue || null }, + }); + (row as any)[colKey] = editValue; + toast.success("저장되었어요"); + } catch { + toast.error("저장에 실패했어요"); + setEditingCell(null); + return; + } + } + + onCellEdit?.(rowId, colKey, editValue, row as T); + setEditingCell(null); + }, [editingCell, editValue, paginatedData, tableName, onCellEdit, rowKey]); + + const cancelEdit = () => setEditingCell(null); + + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { e.preventDefault(); saveEdit(); } + else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); } + else if (e.key === "Tab") { e.preventDefault(); saveEdit(); } + }; + + useEffect(() => { + if (editingCell && editRef.current) { + editRef.current.focus(); + if ("select" in editRef.current) editRef.current.select(); + } + }, [editingCell]); + + // 체크박스 + const allChecked = processedData.length > 0 && checkedIds.length === processedData.length; + + // colSpan 계산 + const colSpan = columns.length + (showCheckbox ? 1 : 0) + (showRowNumber ? 1 : 0); + + // 셀 렌더링 + const renderCell = (row: T, col: EDataTableColumn, rowIdx: number) => { + const id = getRowId(row, rowKey); + const isEditing = editingCell?.rowId === id && editingCell?.colKey === col.key; + const val = row[col.key]; + + // 편집 모드 + if (isEditing) { + if (col.inputType === "select" && col.selectOptions) { + return ( + + ); + } + return ( + setEditValue(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={() => saveEdit()} + className={cn( + "h-8 w-full rounded border border-primary bg-background px-2 text-[13px] focus:ring-1 focus:ring-primary", + col.align === "right" && "text-right" + )} + /> + ); + } + + // 커스텀 렌더러 + if (col.render) { + return col.render(val, row, rowIdx); + } + + // 기본 렌더링 + let display: React.ReactNode = val ?? ""; + if (col.formatNumber || col.inputType === "number") display = fmtNum(val); + + return ( + + {display} + + ); + }; + + return ( +
+
+ + + + c.key)} strategy={horizontalListSortingStrategy}> + + {/* 체크박스 */} + {showCheckbox && ( + + { + onCheckedChange?.(checked ? processedData.map((r) => getRowId(r, rowKey)) : []); + }} + /> + + )} + {/* 행번호 */} + {showRowNumber && ( + + # + + )} + {/* 데이터 컬럼 */} + {columns.map((col) => ( + + ))} + + + + + + {loading ? ( + + + + + + ) : paginatedData.length === 0 ? ( + + +
+ {emptyIcon || } + {emptyMessage} +
+
+
+ ) : ( + paginatedData.map((row, rowIdx) => { + // 그룹 소계 행 처리 + if ((row as any)._isGroupSummary) { + return ( + + {showCheckbox && } + {showRowNumber && } + {columns.map((col) => ( + + {typeof row[col.key] === "number" ? Number(row[col.key]).toLocaleString() : (row[col.key] || "")} + + ))} + + ); + } + + const id = getRowId(row, rowKey); + const isSelected = selectedId != null && String(selectedId) === String(id); + const isChecked = checkedIds.includes(id); + const highlighted = isSelected || isChecked; + + return ( + { + onSelect?.(id); + onRowClick?.(row, pageOffset + rowIdx); + if (showCheckbox && onCheckedChange) { + const next = checkedIds.includes(id) + ? checkedIds.filter((cid) => cid !== id) + : [...checkedIds, id]; + onCheckedChange(next); + } + }} + onDoubleClick={() => onRowDoubleClick?.(row, pageOffset + rowIdx)} + > + {showCheckbox && ( + e.stopPropagation()}> + { + const next = checked + ? [...checkedIds, id] + : checkedIds.filter((cid) => cid !== id); + onCheckedChange?.(next); + }} + /> + + )} + {showRowNumber && ( + + {pageOffset + rowIdx + 1} + + )} + {columns.map((col) => ( + { + if (col.editable) { + e.stopPropagation(); + startEdit(id, col.key, row[col.key]); + } + }} + > + {renderCell(row, col, pageOffset + rowIdx)} + + ))} + + ); + }) + )} +
+
+
+
+ + {/* 페이지네이션 */} + {showPagination && ( +
+
+
+ 전체 + {totalItems.toLocaleString()} + +
+
+ setPageSizeInput(e.target.value)} + onBlur={applyPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }} + className="h-7 w-16 text-center text-xs" + /> + 건씩 보기 +
+
+ +
+ + + {getPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ +
+ { + 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/common/TableSettingsModal.tsx b/frontend/components/common/TableSettingsModal.tsx index b3617a97..40db3a3a 100644 --- a/frontend/components/common/TableSettingsModal.tsx +++ b/frontend/components/common/TableSettingsModal.tsx @@ -652,20 +652,22 @@ export function TableSettingsModal({ - {/* 그룹별 합산 토글 */} -
-
-
그룹별 합산
-
같은 값끼리 그룹핑하여 합산
-
- -
{/* ===== 탭 3: 그룹 설정 ===== */} -
- 사용 가능한 컬럼 + {/* 헤더 + 합산 토글 */} +
+
+ 그룹 컬럼 + + {tempGroups.filter((g) => g.enabled).length}개 선택 + +
+
+ 소계 합산 + +
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/frontend/hooks/useTableSettings.ts b/frontend/hooks/useTableSettings.ts index 10956d0c..7155887c 100644 --- a/frontend/hooks/useTableSettings.ts +++ b/frontend/hooks/useTableSettings.ts @@ -46,6 +46,8 @@ export function useTableSettings( () => initialVisibleKeys || defaultColumns.map((c) => c.key), ); const [baseFilter, setBaseFilter] = useState(); + const [groupColumns, setGroupColumns] = useState([]); + const [groupSumEnabled, setGroupSumEnabled] = useState(false); // 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성) const [filterConfig, setFilterConfig] = useState( @@ -96,6 +98,11 @@ export function useTableSettings( // 기본 데이터 필터 setBaseFilter(settings.baseFilter); + + // 그룹 설정 + const enabledGroups = (settings.groups || []).filter((g) => g.enabled).map((g) => g.columnName); + setGroupColumns(enabledGroups); + setGroupSumEnabled(settings.groupSumEnabled || false); }, [defaultColumns, initialVisibleKeys], ); @@ -148,6 +155,50 @@ export function useTableSettings( [columnWidths], ); + /** + * 데이터를 그룹핑하고 소계 행을 삽입한 배열을 반환합니다. + * groupColumns가 비어있으면 원본 배열을 그대로 반환합니다. + * 소계 행은 _isGroupSummary: true, _groupKey, _groupValue 속성을 가집니다. + */ + const groupData = useCallback( + >(rows: R[]): (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] => { + if (groupColumns.length === 0) return rows; + + const groupCol = groupColumns[0]; // 첫 번째 그룹 컬럼 기준 + const groups = new Map(); + + for (const row of rows) { + const key = String(row[groupCol] ?? "(빈 값)"); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + + const result: (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] = []; + + for (const [groupValue, groupRows] of groups) { + // 그룹 내 데이터 행 + result.push(...groupRows); + + // 소계 행 (groupSumEnabled일 때만) + if (groupSumEnabled) { + const summaryRow: any = { _isGroupSummary: true, _groupKey: groupCol, _groupValue: groupValue }; + // 숫자 컬럼 합산 + for (const col of defaultColumns) { + const values = groupRows.map((r) => Number(r[col.key])).filter((v) => !isNaN(v)); + if (values.length > 0 && values.some((v) => v !== 0)) { + summaryRow[col.key] = values.reduce((a, b) => a + b, 0); + } + } + summaryRow[groupCol] = `${groupValue} 소계 (${groupRows.length}건)`; + result.push(summaryRow); + } + } + + return result; + }, + [groupColumns, groupSumEnabled, defaultColumns], + ); + return { /** 모달 open 상태 */ open, @@ -171,6 +222,12 @@ export function useTableSettings( filterConfig, /** 기본 데이터 필터 (예: division = '판매') */ baseFilter, + /** 데이터 그룹핑 + 소계 삽입 함수 */ + groupData, + /** 그룹 컬럼 목록 */ + groupColumns, + /** 그룹별 합산 활성 여부 */ + groupSumEnabled, /** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */ defaultVisibleKeys: initialVisibleKeys || defaultColumns.map((c) => c.key), }; diff --git a/myfile.txt b/myfile.txt new file mode 100644 index 00000000..ce013625 --- /dev/null +++ b/myfile.txt @@ -0,0 +1 @@ +hello 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 "============================================" diff --git a/test.txt b/test.txt new file mode 100644 index 00000000..ce013625 --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +hello