김주석 의원장님 살려주세요
This commit is contained in:
@@ -95,21 +95,19 @@ html, body { height: 100%; overflow: hidden; font-size: 13px; }
|
|||||||
═══════════════════════════════════════════ */
|
═══════════════════════════════════════════ */
|
||||||
.main-content {
|
.main-content {
|
||||||
display: flex; flex: 1; overflow: hidden;
|
display: flex; flex: 1; overflow: hidden;
|
||||||
background: hsl(var(--background));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Master Panel (Left) */
|
/* Master Panel (Left) */
|
||||||
.panel-master {
|
.panel-master {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
min-width: 250px; overflow: hidden;
|
min-width: 250px; overflow: hidden;
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--background));
|
||||||
border-right: none;
|
|
||||||
}
|
}
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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));
|
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-header-left { display: flex; align-items: center; gap: 10px; }
|
||||||
.panel-title { font-size: 13px; font-weight: 700; color: hsl(var(--foreground)); }
|
.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 */
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
width: 6px; min-width: 6px; cursor: col-resize;
|
width: 5px; min-width: 5px; cursor: col-resize;
|
||||||
background: hsl(var(--border)); transition: background 0.15s;
|
background: hsl(var(--border) / 0.6); transition: all 0.15s;
|
||||||
position: relative; z-index: 10;
|
position: relative; z-index: 10; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.resize-handle:hover, .resize-handle.active {
|
.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) */
|
/* Detail Panel (Right) */
|
||||||
.panel-detail {
|
.panel-detail {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
min-width: 250px; flex: 1; overflow: hidden;
|
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 {
|
.table-wrapper {
|
||||||
flex: 1; overflow: auto; position: relative;
|
flex: 1; overflow: auto; position: relative;
|
||||||
|
background: hsl(var(--background));
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
width: 100%; border-collapse: collapse; table-layout: fixed;
|
width: 100%; border-collapse: collapse; table-layout: fixed;
|
||||||
@@ -156,22 +148,22 @@ thead { position: sticky; top: 0; z-index: 5; }
|
|||||||
thead th {
|
thead th {
|
||||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||||
letter-spacing: 0.05em; color: hsl(var(--muted-foreground));
|
letter-spacing: 0.05em; color: hsl(var(--muted-foreground));
|
||||||
padding: 10px 12px; text-align: left;
|
padding: 9px 12px; text-align: left;
|
||||||
background: hsl(var(--card)); border-bottom: 1px solid hsl(var(--border));
|
background: hsl(var(--muted)); border-bottom: 1px solid hsl(var(--border));
|
||||||
white-space: nowrap; user-select: none;
|
white-space: nowrap; user-select: none;
|
||||||
}
|
}
|
||||||
tbody tr {
|
tbody tr {
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
border-bottom: 1px solid hsl(var(--border) / 0.5);
|
||||||
cursor: pointer; transition: all 0.1s;
|
cursor: pointer; transition: all 0.1s;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
}
|
}
|
||||||
tbody tr:hover { background: hsl(var(--accent)); }
|
tbody tr:hover { background: hsl(var(--accent) / 0.5); }
|
||||||
tbody tr.selected {
|
tbody tr.selected {
|
||||||
background: hsl(var(--primary) / 0.08);
|
background: hsl(var(--primary) / 0.06);
|
||||||
border-left: 3px solid hsl(var(--primary));
|
border-left: 3px solid hsl(var(--primary));
|
||||||
}
|
}
|
||||||
tbody td {
|
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;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
tbody tr.selected td { color: hsl(var(--foreground)); }
|
tbody tr.selected td { color: hsl(var(--foreground)); }
|
||||||
@@ -201,8 +193,8 @@ tbody tr.selected .cell-mono { color: hsl(var(--primary)); }
|
|||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
flex: 1; padding: 40px;
|
flex: 1; padding: 40px;
|
||||||
border: 2px dashed hsl(var(--border)); border-radius: var(--radius);
|
border: 2px dashed hsl(var(--border) / 0.6); border-radius: var(--radius);
|
||||||
margin: 20px; text-align: center;
|
margin: 16px; text-align: center;
|
||||||
}
|
}
|
||||||
.empty-state-icon {
|
.empty-state-icon {
|
||||||
width: 48px; height: 48px; color: hsl(var(--muted-foreground) / 0.5); margin-bottom: 16px;
|
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 {
|
.tabs {
|
||||||
display: flex; border-bottom: 1px solid hsl(var(--border));
|
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 {
|
.tab {
|
||||||
display: flex; align-items: center; gap: 6px;
|
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;
|
color: hsl(var(--muted-foreground)); cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
transition: all 0.15s; user-select: none;
|
transition: all 0.15s; user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.tab:hover { color: hsl(var(--muted-foreground)); }
|
.tab:hover { color: hsl(var(--foreground)); }
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: hsl(var(--foreground)); border-bottom-color: hsl(var(--primary));
|
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 */
|
||||||
.detail-sub-header {
|
.detail-sub-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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-title { font-size: 12px; font-weight: 600; color: hsl(var(--muted-foreground)); }
|
||||||
.detail-sub-actions { display: flex; gap: 6px; }
|
.detail-sub-actions { display: flex; gap: 6px; }
|
||||||
|
|||||||
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||||
ClipboardList, Pencil, Search, X, Truck, Package,
|
ClipboardList, Pencil, Search, X, Truck, Package,
|
||||||
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight,
|
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight,
|
||||||
Settings2, RotateCcw,
|
Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
@@ -27,7 +28,6 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
||||||
|
|
||||||
const DETAIL_TABLE = "sales_order_detail";
|
const DETAIL_TABLE = "sales_order_detail";
|
||||||
const MASTER_TABLE = "sales_order_mng";
|
const MASTER_TABLE = "sales_order_mng";
|
||||||
@@ -42,8 +42,19 @@ const formatNumber = (val: string) => {
|
|||||||
};
|
};
|
||||||
const parseNumber = (val: string) => val.replace(/,/g, "");
|
const parseNumber = (val: string) => val.replace(/,/g, "");
|
||||||
|
|
||||||
const GRID_COLUMNS_CONFIG = [
|
// 마스터 헤더 레이아웃 (수주번호 뒤, 디테일 11컬럼 위에 colSpan으로 맵핑)
|
||||||
{ key: "order_no", label: "수주번호" },
|
// 순서: 거래처 | 단가방식 | 납품처 | 납품장소 | 수주일 | 담당자 → 합계 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_code", label: "품번" },
|
||||||
{ key: "part_name", label: "품명" },
|
{ key: "part_name", label: "품명" },
|
||||||
{ key: "spec", label: "규격" },
|
{ key: "spec", label: "규격" },
|
||||||
@@ -55,9 +66,103 @@ const GRID_COLUMNS_CONFIG = [
|
|||||||
{ key: "amount", label: "금액" },
|
{ key: "amount", label: "금액" },
|
||||||
{ key: "currency_code", label: "통화" },
|
{ key: "currency_code", label: "통화" },
|
||||||
{ key: "due_date", 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: "메모" },
|
{ 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<string>;
|
||||||
|
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 (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
|
||||||
|
hasFilter && "text-primary bg-primary/10",
|
||||||
|
)}
|
||||||
|
title="필터"
|
||||||
|
>
|
||||||
|
<Filter className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between border-b pb-2">
|
||||||
|
<span className="text-xs font-medium">필터: {colLabel}</span>
|
||||||
|
{hasFilter && (
|
||||||
|
<button onClick={() => onClear(colKey)} className="text-primary text-xs hover:underline">
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={filterSearch}
|
||||||
|
onChange={(e) => setFilterSearch(e.target.value)}
|
||||||
|
placeholder="검색..."
|
||||||
|
className="h-7 text-xs pl-7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-52 space-y-0.5 overflow-y-auto">
|
||||||
|
{filteredValues.slice(0, 100).map((val) => {
|
||||||
|
const isSelected = filterValues.has(val);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={val}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
|
||||||
|
isSelected && "bg-primary/10",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggle(colKey, val)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
|
||||||
|
isSelected ? "bg-primary border-primary" : "border-input",
|
||||||
|
)}>
|
||||||
|
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{val || "(빈 값)"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredValues.length > 100 && (
|
||||||
|
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||||
|
...외 {filteredValues.length - 100}개
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SalesOrderPage() {
|
export default function SalesOrderPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
@@ -75,7 +180,6 @@ export default function SalesOrderPage() {
|
|||||||
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
|
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
|
||||||
const [detailRows, setDetailRows] = useState<any[]>([]);
|
const [detailRows, setDetailRows] = useState<any[]>([]);
|
||||||
const [allowPriceEdit, setAllowPriceEdit] = useState(true);
|
const [allowPriceEdit, setAllowPriceEdit] = useState(true);
|
||||||
const [orderGroups, setOrderGroups] = useState<Record<string, { master: any; details: any[] }>>({});
|
|
||||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
||||||
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
|
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -110,6 +214,10 @@ export default function SalesOrderPage() {
|
|||||||
// 테이블 설정
|
// 테이블 설정
|
||||||
const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
|
const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
|
||||||
|
|
||||||
|
// 헤더 필터 & 정렬
|
||||||
|
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||||
|
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
|
||||||
|
|
||||||
// 카테고리 로드
|
// 카테고리 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
@@ -257,16 +365,6 @@ export default function SalesOrderPage() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// order_no 기준 그룹핑
|
|
||||||
const grouped: Record<string, { master: any; details: any[] }> = {};
|
|
||||||
for (const row of data) {
|
|
||||||
const key = row.order_no || "_no_order";
|
|
||||||
if (!grouped[key]) {
|
|
||||||
grouped[key] = { master: row._master || {}, details: [] };
|
|
||||||
}
|
|
||||||
grouped[key].details.push(row);
|
|
||||||
}
|
|
||||||
setOrderGroups(grouped);
|
|
||||||
setOrders(data);
|
setOrders(data);
|
||||||
setTotalCount(res.data?.data?.total || data.length);
|
setTotalCount(res.data?.data?.total || data.length);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -278,6 +376,160 @@ export default function SalesOrderPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||||
|
|
||||||
|
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
|
||||||
|
const columnUniqueValues = useMemo(() => {
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
for (const col of DETAIL_HEADER_COLS) {
|
||||||
|
const values = new Set<string>();
|
||||||
|
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<string, { master: any; details: any[] }> = {};
|
||||||
|
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<string, Set<string>> = {};
|
||||||
|
const detailFilters: Record<string, Set<string>> = {};
|
||||||
|
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<string, string[]> = {};
|
||||||
|
// 필터 전 전체 마스터에서 고유값 추출
|
||||||
|
const seenMasters = new Map<string, any>();
|
||||||
|
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<string>();
|
||||||
|
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) => {
|
const getCategoryLabel = (col: string, code: string) => {
|
||||||
if (!code) return "";
|
if (!code) return "";
|
||||||
const found = categoryOptions[col]?.find((o) => o.code === code);
|
const found = categoryOptions[col]?.find((o) => o.code === code);
|
||||||
@@ -341,29 +593,19 @@ export default function SalesOrderPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 삭제 (다중 선택)
|
// 삭제 (마스터 단위)
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||||
const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, {
|
const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, {
|
||||||
description: "삭제된 데이터는 복구할 수 없습니다.",
|
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
confirmText: "삭제",
|
confirmText: "삭제",
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
|
||||||
data: checkedIds.map((id) => ({ id })),
|
|
||||||
});
|
|
||||||
for (const orderNo of orderNos) {
|
for (const orderNo of orderNos) {
|
||||||
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_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`, {
|
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
page: 1, size: 1,
|
page: 1, size: 1,
|
||||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||||
@@ -376,7 +618,6 @@ export default function SalesOrderPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
toast.success("삭제되었습니다.");
|
toast.success("삭제되었습니다.");
|
||||||
setCheckedIds([]);
|
setCheckedIds([]);
|
||||||
fetchOrders();
|
fetchOrders();
|
||||||
@@ -646,56 +887,120 @@ export default function SalesOrderPage() {
|
|||||||
{/* 데이터 테이블 (트리 구조) */}
|
{/* 데이터 테이블 (트리 구조) */}
|
||||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||||
<div className="h-full overflow-auto">
|
<div className="h-full overflow-auto">
|
||||||
<Table style={{ minWidth: "1200px" }}>
|
<Table style={{ minWidth: "1500px" }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: "40px" }} />
|
<col style={{ width: "40px" }} /> {/* 체크박스 */}
|
||||||
<col style={{ width: "36px" }} />
|
<col style={{ width: "36px" }} /> {/* 펼침 화살표 */}
|
||||||
<col style={{ width: "160px" }} />
|
<col style={{ width: "150px" }} /> {/* 수주번호 */}
|
||||||
<col style={{ width: "120px" }} />
|
<col style={{ width: "120px" }} /> {/* 품번 / 거래처 */}
|
||||||
<col style={{ width: "160px" }} />
|
<col style={{ width: "140px" }} /> {/* 품명 / 거래처(cont) */}
|
||||||
<col style={{ width: "100px" }} />
|
<col style={{ width: "80px" }} /> {/* 규격 / 단가방식 */}
|
||||||
<col style={{ width: "70px" }} />
|
<col style={{ width: "70px" }} /> {/* 단위 / 납품처 */}
|
||||||
<col style={{ width: "80px" }} />
|
<col style={{ width: "80px" }} /> {/* 수량 / 납품처(cont) */}
|
||||||
<col style={{ width: "80px" }} />
|
<col style={{ width: "80px" }} /> {/* 출하수량 / 납품장소 */}
|
||||||
<col style={{ width: "80px" }} />
|
<col style={{ width: "80px" }} /> {/* 잔량 / 납품장소(cont) */}
|
||||||
<col style={{ width: "100px" }} />
|
<col style={{ width: "90px" }} /> {/* 단가 / 수주일 */}
|
||||||
<col style={{ width: "120px" }} />
|
<col style={{ width: "110px" }} /> {/* 금액 / 수주일(cont) */}
|
||||||
<col style={{ width: "110px" }} />
|
<col style={{ width: "60px" }} /> {/* 통화 / 담당자 */}
|
||||||
<col style={{ width: "120px" }} />
|
<col style={{ width: "100px" }} /> {/* 납기일 / 담당자(cont) */}
|
||||||
|
<col style={{ width: "120px" }} /> {/* 메모 */}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<TableHeader className="sticky top-0 z-10">
|
<TableHeader className="sticky top-0 z-10">
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
<TableHead className="text-center">
|
<TableHead
|
||||||
|
className="text-center cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={orders.length > 0 && checkedIds.length === orders.length}
|
checked={(() => {
|
||||||
onCheckedChange={(checked) => setCheckedIds(checked ? orders.map((o) => o.id) : [])}
|
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
|
||||||
|
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
|
||||||
|
})()}
|
||||||
|
onCheckedChange={() => {}}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead />
|
<TableHead />
|
||||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead>
|
{/* 수주번호 (별도 컬럼) */}
|
||||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
|
||||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
<div className="inline-flex items-center gap-1">
|
||||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("order_no")}>
|
||||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
<span className="truncate">수주번호</span>
|
||||||
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
{sortState?.key === "order_no" && (
|
||||||
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하수량</TableHead>
|
sortState.direction === "asc"
|
||||||
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>
|
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
|
||||||
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
|
||||||
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
)}
|
||||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
</div>
|
||||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>
|
{(masterUniqueValues["order_no"] || []).length > 0 && (
|
||||||
|
<HeaderFilterPopover
|
||||||
|
colKey="order_no" colLabel="수주번호"
|
||||||
|
uniqueValues={masterUniqueValues["order_no"] || []}
|
||||||
|
filterValues={headerFilters["order_no"] || new Set<string>()}
|
||||||
|
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
{/* 마스터 필드 헤더 (colSpan으로 디테일 컬럼 위에 맵핑) */}
|
||||||
|
{MASTER_BODY_LAYOUT.map((col) => (
|
||||||
|
<TableHead key={col.key} colSpan={col.colSpan} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort(col.key)}>
|
||||||
|
<span className="truncate">{col.label}</span>
|
||||||
|
{sortState?.key === col.key && (
|
||||||
|
sortState.direction === "asc"
|
||||||
|
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(masterUniqueValues[col.key] || []).length > 0 && (
|
||||||
|
<HeaderFilterPopover
|
||||||
|
colKey={col.key} colLabel={col.label}
|
||||||
|
uniqueValues={masterUniqueValues[col.key] || []}
|
||||||
|
filterValues={headerFilters[col.key] || new Set<string>()}
|
||||||
|
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
{/* 메모 (마스터) */}
|
||||||
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<div className="flex items-center gap-1 cursor-pointer min-w-0" onClick={() => handleSort("memo")}>
|
||||||
|
<span className="truncate">메모</span>
|
||||||
|
{sortState?.key === "memo" && (
|
||||||
|
sortState.direction === "asc"
|
||||||
|
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(masterUniqueValues["memo"] || []).length > 0 && (
|
||||||
|
<HeaderFilterPopover
|
||||||
|
colKey="memo" colLabel="메모"
|
||||||
|
uniqueValues={masterUniqueValues["memo"] || []}
|
||||||
|
filterValues={headerFilters["memo"] || new Set<string>()}
|
||||||
|
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={14} className="py-16 text-center">
|
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
|
||||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
|
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : Object.keys(orderGroups).length === 0 ? (
|
) : Object.keys(filteredOrderGroups).length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={14} className="py-16 text-center">
|
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
<ClipboardList className="h-8 w-8 opacity-30" />
|
<ClipboardList className="h-8 w-8 opacity-30" />
|
||||||
<span className="text-sm">등록된 수주가 없어요</span>
|
<span className="text-sm">등록된 수주가 없어요</span>
|
||||||
@@ -703,18 +1008,15 @@ export default function SalesOrderPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(orderGroups).map(([orderNo, group]) => {
|
Object.entries(filteredOrderGroups).map(([orderNo, group]) => {
|
||||||
const isExpanded = expandedOrders.has(orderNo);
|
const isExpanded = expandedOrders.has(orderNo);
|
||||||
const detailIds = group.details.map((d) => d.id);
|
const detailIds = group.details.map((d) => d.id);
|
||||||
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
|
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
|
||||||
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
|
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
|
||||||
const master = group.master;
|
const master = group.master;
|
||||||
const totalQty = group.details.reduce((s, d) => s + (parseFloat(d.qty) || 0), 0);
|
|
||||||
const totalAmount = group.details.reduce((s, d) => s + (parseFloat(d.amount) || 0), 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={orderNo}>
|
<React.Fragment key={orderNo}>
|
||||||
{/* 마스터 행 */}
|
{/* 마스터 행 — 마스터 테이블 필드만 표시 */}
|
||||||
<TableRow
|
<TableRow
|
||||||
style={{ borderTop: "2px solid hsl(var(--border))" }}
|
style={{ borderTop: "2px solid hsl(var(--border))" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -723,7 +1025,6 @@ export default function SalesOrderPage() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (expandedOrders.has(orderNo)) {
|
if (expandedOrders.has(orderNo)) {
|
||||||
// 접기 — 애니메이션 후 제거
|
|
||||||
setClosingOrders((prev) => new Set(prev).add(orderNo));
|
setClosingOrders((prev) => new Set(prev).add(orderNo));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; });
|
setExpandedOrders((prev) => { const next = new Set(prev); next.delete(orderNo); return next; });
|
||||||
@@ -735,16 +1036,20 @@ export default function SalesOrderPage() {
|
|||||||
}}
|
}}
|
||||||
onDoubleClick={() => openEditModal(orderNo)}
|
onDoubleClick={() => openEditModal(orderNo)}
|
||||||
>
|
>
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
<TableCell
|
||||||
|
className="text-center cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCheckedIds((prev) => {
|
||||||
|
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
|
||||||
|
return [...new Set([...prev, ...detailIds])];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allDetailChecked}
|
checked={allDetailChecked}
|
||||||
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
|
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={() => {}}
|
||||||
setCheckedIds((prev) => {
|
|
||||||
if (checked) return [...new Set([...prev, ...detailIds])];
|
|
||||||
return prev.filter((id) => !detailIds.includes(id));
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
@@ -753,25 +1058,100 @@ export default function SalesOrderPage() {
|
|||||||
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
|
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
}
|
}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono whitespace-nowrap">{orderNo}</TableCell>
|
{/* 수주번호 */}
|
||||||
<TableCell colSpan={4} className="text-muted-foreground truncate max-w-0">
|
<TableCell className="font-mono whitespace-nowrap">
|
||||||
<span className="truncate block">
|
{orderNo}
|
||||||
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground opacity-60">({group.details.length})</span>
|
||||||
|
</TableCell>
|
||||||
|
{/* 거래처 (colSpan=2) */}
|
||||||
|
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
|
||||||
|
<span className="block truncate">
|
||||||
{master.partner_id ? (categoryOptions["partner_id"]?.find((o) => o.code === master.partner_id)?.label || master.partner_id) : ""}
|
{master.partner_id ? (categoryOptions["partner_id"]?.find((o) => o.code === master.partner_id)?.label || master.partner_id) : ""}
|
||||||
{master.order_date ? ` · ${master.order_date}` : ""}
|
|
||||||
<span className="ml-2 text-xs opacity-60">({group.details.length}건)</span>
|
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-mono whitespace-nowrap">{totalQty ? totalQty.toLocaleString() : ""}</TableCell>
|
{/* 단가방식 (colSpan=1) */}
|
||||||
<TableCell />
|
<TableCell className="text-[13px] truncate max-w-0">
|
||||||
<TableCell />
|
<span className="block truncate">
|
||||||
<TableCell />
|
{master.price_mode ? (categoryOptions["price_mode"]?.find((o) => o.code === master.price_mode)?.label || master.price_mode) : ""}
|
||||||
<TableCell className="text-right font-mono whitespace-nowrap">{totalAmount ? totalAmount.toLocaleString() : ""}</TableCell>
|
</span>
|
||||||
<TableCell className="whitespace-nowrap">{master.due_date || ""}</TableCell>
|
</TableCell>
|
||||||
|
{/* 납품처 (colSpan=2) */}
|
||||||
|
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
|
||||||
|
<span className="block truncate">{master.delivery_partner_id || ""}</span>
|
||||||
|
</TableCell>
|
||||||
|
{/* 납품장소 (colSpan=2) */}
|
||||||
|
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
|
||||||
|
<span className="block truncate">{master.delivery_address || ""}</span>
|
||||||
|
</TableCell>
|
||||||
|
{/* 수주일 (colSpan=2) */}
|
||||||
|
<TableCell colSpan={2} className="whitespace-nowrap text-[13px]">
|
||||||
|
{master.order_date || ""}
|
||||||
|
</TableCell>
|
||||||
|
{/* 담당자 (colSpan=2) */}
|
||||||
|
<TableCell colSpan={2} className="text-[13px] truncate max-w-0">
|
||||||
|
<span className="block truncate">
|
||||||
|
{master.manager_id ? (categoryOptions["manager_id"]?.find((o) => o.code === master.manager_id)?.label || master.manager_id) : ""}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
{/* 메모 */}
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
<span className="block truncate max-w-[100px]">{master.memo || ""}</span>
|
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
|
{/* 디테일 서브 헤더 (펼쳤을 때만) */}
|
||||||
|
{isExpanded && (
|
||||||
|
<TableRow
|
||||||
|
className={cn(
|
||||||
|
"border-l-[3px] border-l-primary/30 bg-muted/60",
|
||||||
|
closingOrders.has(orderNo) ? "tree-detail-row-closing" : "tree-detail-row",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TableCell />
|
||||||
|
<TableCell />
|
||||||
|
<TableCell /> {/* 수주번호 컬럼 빈 셀 */}
|
||||||
|
{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<string>();
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 select-none",
|
||||||
|
isRight && "text-right",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("inline-flex items-center gap-1", isRight && "justify-end w-full")}>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 cursor-pointer min-w-0"
|
||||||
|
onClick={() => handleSort(col.key)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{col.label}</span>
|
||||||
|
{isSorted && (
|
||||||
|
sortState!.direction === "asc"
|
||||||
|
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{uniqueVals.length > 0 && (
|
||||||
|
<HeaderFilterPopover
|
||||||
|
colKey={col.key} colLabel={col.label}
|
||||||
|
uniqueValues={uniqueVals} filterValues={filterVals}
|
||||||
|
onToggle={toggleHeaderFilter} onClear={clearHeaderFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 디테일 행 (펼쳤을 때만) */}
|
{/* 디테일 행 (펼쳤을 때만) */}
|
||||||
{isExpanded && group.details.map((row, detailIdx) => {
|
{isExpanded && group.details.map((row, detailIdx) => {
|
||||||
const isClosing = closingOrders.has(orderNo);
|
const isClosing = closingOrders.has(orderNo);
|
||||||
@@ -791,20 +1171,21 @@ export default function SalesOrderPage() {
|
|||||||
}}
|
}}
|
||||||
onDoubleClick={() => openEditModal(row.order_no)}
|
onDoubleClick={() => openEditModal(row.order_no)}
|
||||||
>
|
>
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
<TableCell
|
||||||
<Checkbox
|
className="text-center cursor-pointer"
|
||||||
checked={isChecked}
|
onClick={(e) => {
|
||||||
onCheckedChange={(checked) => {
|
e.stopPropagation();
|
||||||
setCheckedIds((prev) =>
|
setCheckedIds((prev) =>
|
||||||
checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)
|
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="relative">
|
<TableCell className="relative">
|
||||||
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
|
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell />
|
<TableCell /> {/* 수주번호 컬럼 빈 셀 */}
|
||||||
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
|
<TableCell className="font-mono text-[13px]">{row.part_code}</TableCell>
|
||||||
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
|
<TableCell className="text-[13px] max-w-[150px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
|
||||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||||
@@ -814,6 +1195,7 @@ export default function SalesOrderPage() {
|
|||||||
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
|
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
|
||||||
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
|
||||||
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||||
|
<TableCell className="text-[13px]">{row.currency_code || ""}</TableCell>
|
||||||
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
|
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
|
||||||
<TableCell />
|
<TableCell />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1068,11 +1068,11 @@ body span.messenger-time {
|
|||||||
background-color: hsl(var(--primary)) !important;
|
background-color: hsl(var(--primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 짝수 행 stripe — 트리 행(master/detail)은 제외 */
|
/* 짝수 행 stripe — 트리 행(master/detail)과 선택 행은 제외 */
|
||||||
[data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):nth-child(even) {
|
[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);
|
background-color: hsl(var(--muted) / 0.35);
|
||||||
}
|
}
|
||||||
.dark [data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):nth-child(even) {
|
.dark [data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):not(.row-selected):nth-child(even) {
|
||||||
background-color: hsl(var(--muted) / 0.18);
|
background-color: hsl(var(--muted) / 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function SortableHeaderCell({
|
|||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
col.width, col.minWidth,
|
col.width, col.minWidth,
|
||||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative",
|
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative overflow-hidden",
|
||||||
col.align === "right" && "text-right",
|
col.align === "right" && "text-right",
|
||||||
col.align === "center" && "text-center",
|
col.align === "center" && "text-center",
|
||||||
)}
|
)}
|
||||||
@@ -586,7 +586,7 @@ export function EDataTable<T extends Record<string, any> = any>({
|
|||||||
<Table className="min-w-max">
|
<Table className="min-w-max">
|
||||||
<TableHeader className="sticky top-0 z-10">
|
<TableHeader className="sticky top-0 z-10">
|
||||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
<TableRow className="bg-muted hover:bg-muted h-10">
|
||||||
{/* 체크박스 */}
|
{/* 체크박스 */}
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<TableHead className="w-10 text-center">
|
<TableHead className="w-10 text-center">
|
||||||
@@ -663,7 +663,7 @@ export function EDataTable<T extends Record<string, any> = any>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = getRowId(row, rowKey);
|
const id = getRowId(row, rowKey);
|
||||||
const isSelected = selectedId === id;
|
const isSelected = selectedId != null && String(selectedId) === String(id);
|
||||||
const isChecked = checkedIds.includes(id);
|
const isChecked = checkedIds.includes(id);
|
||||||
const highlighted = isSelected || isChecked;
|
const highlighted = isSelected || isChecked;
|
||||||
|
|
||||||
@@ -671,9 +671,9 @@ export function EDataTable<T extends Record<string, any> = any>({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={id || rowIdx}
|
key={id || rowIdx}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
"cursor-pointer border-l-[3px] border-l-transparent transition-all h-[41px]",
|
||||||
highlighted
|
highlighted
|
||||||
? "border-l-primary bg-primary/5"
|
? "border-l-primary bg-primary/20 dark:bg-primary/15 row-selected"
|
||||||
: "hover:bg-accent"
|
: "hover:bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -787,7 +787,33 @@ export function EDataTable<T extends Record<string, any> = any>({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[180px]" />
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
placeholder={String(safePage)}
|
||||||
|
className="h-7 w-14 text-center text-xs"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
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 = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>/ {totalPages} 페이지</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
|
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b border-border/60 transition-colors", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Executable
+168
@@ -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 "============================================"
|
||||||
Reference in New Issue
Block a user