Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -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; }
|
||||
|
||||
205
docs/customer-management-tables.md
Normal file
205
docs/customer-management-tables.md
Normal file
@@ -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
|
||||
@@ -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() {
|
||||
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{currentTab === "ecr" ? (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
{tsEcr.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.key === "request_no" && "w-[140px]",
|
||||
col.key === "change_type" && "w-[90px] text-center",
|
||||
col.key === "status" && "w-[90px] text-center",
|
||||
col.key === "urgency" && "w-[60px] text-center",
|
||||
col.key === "target_name" && "w-[200px]",
|
||||
col.key === "drawing_no" && "w-[150px]",
|
||||
col.key === "req_dept" && "w-[80px]",
|
||||
col.key === "requester" && "w-[70px]",
|
||||
col.key === "request_date" && "w-[100px]",
|
||||
col.key === "ecn_no" && "w-[130px]",
|
||||
)}
|
||||
style={tsEcr.getWidth(col.key) ? { width: tsEcr.getWidth(col.key) } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEcr.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tsEcr.visibleColumns.length + 1} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Inbox className="w-8 h-8 text-muted-foreground/50" />
|
||||
<span>조건에 맞는 ECR이 없어요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredEcr.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedId === item.id && "bg-primary/5"
|
||||
)}
|
||||
onClick={() => handleRowClick(item.id)}
|
||||
>
|
||||
<TableCell className="text-center text-muted-foreground">{idx + 1}</TableCell>
|
||||
{tsEcr.isVisible("request_no") && <TableCell style={tsEcr.thStyle("request_no")} className="font-semibold text-primary">{item.id}</TableCell>}
|
||||
{tsEcr.isVisible("change_type") && (
|
||||
<TableCell style={tsEcr.thStyle("change_type")} className="text-center">
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getChangeTypeStyle(item.changeType))}>
|
||||
{item.changeType}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
{tsEcr.isVisible("status") && (
|
||||
<TableCell style={tsEcr.thStyle("status")} className="text-center">
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcrStatusStyle(item.status))}>
|
||||
{item.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
{tsEcr.isVisible("urgency") && (
|
||||
<TableCell style={tsEcr.thStyle("urgency")} className="text-center">
|
||||
{item.urgency === "긴급" ? (
|
||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-medium border bg-destructive/10 text-destructive border-destructive/20">
|
||||
긴급
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{tsEcr.isVisible("target_name") && <TableCell style={tsEcr.thStyle("target_name")} className="font-medium">{item.target}</TableCell>}
|
||||
{tsEcr.isVisible("drawing_no") && <TableCell style={tsEcr.thStyle("drawing_no")} className="text-[13px] text-muted-foreground">{item.drawingNo}</TableCell>}
|
||||
{tsEcr.isVisible("req_dept") && <TableCell style={tsEcr.thStyle("req_dept")}>{item.reqDept}</TableCell>}
|
||||
{tsEcr.isVisible("requester") && <TableCell style={tsEcr.thStyle("requester")}>{item.requester}</TableCell>}
|
||||
{tsEcr.isVisible("request_date") && <TableCell style={tsEcr.thStyle("request_date")}>{item.date}</TableCell>}
|
||||
{tsEcr.isVisible("ecn_no") && (
|
||||
<TableCell style={tsEcr.thStyle("ecn_no")}>
|
||||
{item.ecnNo ? (
|
||||
<button
|
||||
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-info/10 text-info border border-info/20 hover:bg-info/20 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToLink(item.ecnNo);
|
||||
}}
|
||||
>
|
||||
{item.ecnNo} <ArrowRight className="w-3 h-3 inline" />
|
||||
</button>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "id", label: "ECR번호", width: "w-[140px]", render: (val: any) => <span className="font-semibold text-primary">{val}</span> },
|
||||
{ key: "changeType", label: "변경유형", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getChangeTypeStyle(val))}>{val}</span> },
|
||||
{ key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcrStatusStyle(val))}>{val}</span> },
|
||||
{ key: "urgency", label: "긴급", width: "w-[60px]", align: "center" as const, render: (val: any) => val === "긴급" ? <span className="px-2 py-0.5 rounded-full text-[11px] font-medium border bg-destructive/10 text-destructive border-destructive/20">긴급</span> : <span>-</span> },
|
||||
{ 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 ? <button className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-info/10 text-info border border-info/20 hover:bg-info/20 transition-colors" onClick={(e) => { e.stopPropagation(); navigateToLink(val); }}>{val} <ArrowRight className="w-3 h-3 inline" /></button> : <span>-</span> },
|
||||
] as EDataTableColumn<EcrItem>[]}
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
{tsEcn.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.key === "ecn_no" && "w-[140px]",
|
||||
col.key === "status" && "w-[90px] text-center",
|
||||
col.key === "target" && "w-[200px]",
|
||||
col.key === "drawing_after" && "w-[160px]",
|
||||
col.key === "designer" && "w-[80px]",
|
||||
col.key === "ecn_date" && "w-[100px]",
|
||||
col.key === "apply_date" && "w-[100px]",
|
||||
col.key === "notify_depts" && "w-[140px]",
|
||||
col.key === "ecr_id" && "w-[130px]",
|
||||
)}
|
||||
style={tsEcn.getWidth(col.key) ? { width: tsEcn.getWidth(col.key) } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEcn.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tsEcn.visibleColumns.length + 1} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Inbox className="w-8 h-8 text-muted-foreground/50" />
|
||||
<span>조건에 맞는 ECN이 없어요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredEcn.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedId === item.id && "bg-primary/5"
|
||||
)}
|
||||
onClick={() => handleRowClick(item.id)}
|
||||
>
|
||||
<TableCell className="text-center text-muted-foreground">{idx + 1}</TableCell>
|
||||
{tsEcn.isVisible("ecn_no") && <TableCell style={tsEcn.thStyle("ecn_no")} className="font-semibold text-primary">{item.id}</TableCell>}
|
||||
{tsEcn.isVisible("status") && (
|
||||
<TableCell style={tsEcn.thStyle("status")} className="text-center">
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcnStatusStyle(item.status))}>
|
||||
{item.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
{tsEcn.isVisible("target") && <TableCell style={tsEcn.thStyle("target")} className="font-medium">{item.target}</TableCell>}
|
||||
{tsEcn.isVisible("drawing_after") && <TableCell style={tsEcn.thStyle("drawing_after")} className="text-[13px] text-success font-medium">{item.drawingAfter}</TableCell>}
|
||||
{tsEcn.isVisible("designer") && <TableCell style={tsEcn.thStyle("designer")}>{item.designer}</TableCell>}
|
||||
{tsEcn.isVisible("ecn_date") && <TableCell style={tsEcn.thStyle("ecn_date")}>{item.date}</TableCell>}
|
||||
{tsEcn.isVisible("apply_date") && <TableCell style={tsEcn.thStyle("apply_date")}>{item.applyDate}</TableCell>}
|
||||
{tsEcn.isVisible("notify_depts") && <TableCell style={tsEcn.thStyle("notify_depts")} className="text-[13px] text-muted-foreground">{item.notifyDepts.join(", ")}</TableCell>}
|
||||
{tsEcn.isVisible("ecr_id") && (
|
||||
<TableCell style={tsEcn.thStyle("ecr_id")}>
|
||||
<button
|
||||
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-warning/10 text-warning border border-warning/20 hover:bg-warning/20 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToLink(item.ecrNo);
|
||||
}}
|
||||
>
|
||||
{item.ecrNo} <ArrowRight className="w-3 h-3 inline" />
|
||||
</button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "id", label: "ECN번호", width: "w-[140px]", render: (val: any) => <span className="font-semibold text-primary">{val}</span> },
|
||||
{ key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcnStatusStyle(val))}>{val}</span> },
|
||||
{ key: "target", label: "대상 품목/설비", width: "w-[200px]" },
|
||||
{ key: "drawingAfter", label: "도면 (변경 후)", width: "w-[160px]", render: (val: any) => <span className="text-[13px] text-success font-medium">{val}</span> },
|
||||
{ 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) => <span className="text-[13px] text-muted-foreground">{Array.isArray(val) ? val.join(", ") : val}</span> },
|
||||
{ key: "ecrNo", label: "관련 ECR", width: "w-[130px]", render: (val: any) => <button className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-warning/10 text-warning border border-warning/20 hover:bg-warning/20 transition-colors" onClick={(e) => { e.stopPropagation(); navigateToLink(val); }}>{val} <ArrowRight className="w-3 h-3 inline" /></button> },
|
||||
] as EDataTableColumn<EcnItem>[]}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">불러오는 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
col.key === "request_no" && "w-[100px]",
|
||||
col.key === "design_type" && "w-[70px] text-center",
|
||||
col.key === "status" && "w-[70px] text-center",
|
||||
col.key === "priority" && "w-[60px] text-center",
|
||||
col.key === "customer" && "w-[90px]",
|
||||
col.key === "designer" && "w-[70px]",
|
||||
col.key === "due_date" && "w-[85px]",
|
||||
col.key === "progress" && "w-[65px] text-center",
|
||||
)}
|
||||
style={ts.thStyle(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRequests.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-1 text-muted-foreground">
|
||||
<Inbox className="h-8 w-8" />
|
||||
<span className="text-sm">등록된 설계의뢰가 없어요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredRequests.map((item) => {
|
||||
const progress = getProgress(item.status);
|
||||
<EDataTable<DesignRequest>
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn<DesignRequest> => ({
|
||||
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) => <span className="text-[11px] font-semibold text-primary">{val || "-"}</span>
|
||||
: col.key === "design_type"
|
||||
? (val: any) => val ? <Badge className={cn("text-[9px]", TYPE_STYLES[val])}>{val}</Badge> : <span>-</span>
|
||||
: col.key === "status"
|
||||
? (val: any) => <Badge className={cn("text-[9px]", STATUS_STYLES[val])}>{val}</Badge>
|
||||
: col.key === "priority"
|
||||
? (val: any) => <Badge className={cn("text-[9px]", PRIORITY_STYLES[val])}>{val}</Badge>
|
||||
: col.key === "progress"
|
||||
? (_val: any, row: DesignRequest) => {
|
||||
const progress = STATUS_PROGRESS[row.status] ?? 0;
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn("cursor-pointer", selectedId === item.id && "bg-accent")}
|
||||
onClick={() => handleRowClick(item.id)}
|
||||
>
|
||||
{ts.isVisible("request_no") && <TableCell className="text-[11px] font-semibold text-primary" style={ts.thStyle("request_no")}>{item.request_no || "-"}</TableCell>}
|
||||
{ts.isVisible("design_type") && (
|
||||
<TableCell className="text-center" style={ts.thStyle("design_type")}>
|
||||
{item.design_type ? (
|
||||
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("status") && (
|
||||
<TableCell className="text-center" style={ts.thStyle("status")}>
|
||||
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("priority") && (
|
||||
<TableCell className="text-center" style={ts.thStyle("priority")}>
|
||||
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("target_name") && <TableCell className="text-[13px] font-medium" style={ts.thStyle("target_name")}>{item.target_name || "-"}</TableCell>}
|
||||
{ts.isVisible("customer") && <TableCell className="text-[11px]" style={ts.thStyle("customer")}>{item.customer || "-"}</TableCell>}
|
||||
{ts.isVisible("designer") && <TableCell className="text-[11px]" style={ts.thStyle("designer")}>{item.designer || "-"}</TableCell>}
|
||||
{ts.isVisible("due_date") && <TableCell className="text-[11px]" style={ts.thStyle("due_date")}>{item.due_date || "-"}</TableCell>}
|
||||
{ts.isVisible("progress") && (
|
||||
<TableCell style={ts.thStyle("progress")}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
: undefined,
|
||||
}))}
|
||||
data={ts.groupData(filteredRequests)}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 설계의뢰가 없어요"
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
onRowClick={(row) => handleRowClick(row.id)}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 다이얼로그 */}
|
||||
|
||||
@@ -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" && (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide">프로젝트</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide">업무명</TableHead>
|
||||
<TableHead className="w-[65px] text-[11px] font-bold uppercase tracking-wide">유형</TableHead>
|
||||
<TableHead className="w-[55px] text-center text-[11px] font-bold uppercase tracking-wide">상태</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide">종료일</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide">진행률</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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<string, number> = { 진행중: 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 (
|
||||
<TableRow
|
||||
key={`${t.projectId}-${t.name}`}
|
||||
className={cn("cursor-pointer transition-colors hover:bg-muted/30", isSelected && "bg-primary/5")}
|
||||
onClick={() => handleSelectTask(t.projectId, t.name)}
|
||||
>
|
||||
<TableCell className="text-[10px]">
|
||||
<span className="font-semibold text-primary">{t.projectId}</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">{t.projectName}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{t.name}</TableCell>
|
||||
<TableCell className="text-[11px]">{t.category}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[10px]", STATUS_STYLES[displayStatus])}>{displayStatus}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-[11px]", isDelay && "font-semibold text-destructive")}>{t.end}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full", getProgressBg(t.progress))} style={{ width: `${t.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px]">{t.progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{filteredTasks.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-[13px] text-muted-foreground">검색 결과가 없어요</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "projectId", label: "프로젝트", width: "w-[90px]", render: (_v, row) => (
|
||||
<div className="text-[10px]">
|
||||
<span className="font-semibold text-primary">{row.projectId}</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">{row.projectName}</span>
|
||||
</div>
|
||||
)},
|
||||
{ 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 <Badge className={cn("text-[10px]", STATUS_STYLES[displayStatus])}>{displayStatus}</Badge>;
|
||||
}},
|
||||
{ key: "end", label: "종료일", width: "w-[80px]", render: (v, row) => {
|
||||
const isDelay = row.status !== "완료" && new Date(row.end) < today;
|
||||
return <span className={cn("text-[11px]", isDelay && "font-semibold text-destructive")}>{v}</span>;
|
||||
}},
|
||||
{ key: "progress", label: "진행률", width: "w-[70px]", sortable: true, render: (v) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1 w-12 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full", getProgressBg(v))} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px]">{v}%</span>
|
||||
</div>
|
||||
)},
|
||||
] as EDataTableColumn<MyTask>[]}
|
||||
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<string, number> = { 진행중: 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" && (
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"text-[11px] uppercase tracking-wide",
|
||||
col.key === "status" && "w-[80px] text-center",
|
||||
col.key === "project_no" && "w-[160px]",
|
||||
col.key === "name" && "w-[200px]",
|
||||
col.key === "pm" && "w-[70px]",
|
||||
col.key === "customer" && "w-[80px]",
|
||||
col.key === "start_date" && "w-[90px]",
|
||||
col.key === "end_date" && "w-[90px]",
|
||||
col.key === "progress" && "w-[100px] text-center",
|
||||
col.key === "source_no" && "w-[90px]",
|
||||
)}
|
||||
style={ts.thStyle(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length} className="h-32 text-center text-muted-foreground">
|
||||
<Loader2 className="w-10 h-10 mx-auto mb-2 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm">로딩 중...</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : treeRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center gap-2 py-6 mx-auto max-w-xs border border-dashed border-border rounded-lg">
|
||||
<Rocket className="w-10 h-10 text-muted-foreground/30" />
|
||||
<span className="text-sm text-muted-foreground">조건에 맞는 프로젝트가 없어요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
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 (
|
||||
<TableRow
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors border-l-[3px]",
|
||||
selectedId === p.id
|
||||
? "bg-primary/5 border-l-primary"
|
||||
: "border-l-transparent",
|
||||
depth === 1 && "bg-muted/30",
|
||||
depth >= 2 && "bg-muted/15"
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedId(p.id);
|
||||
setDetailTab("wbs");
|
||||
fetchTaskDetails(p.id);
|
||||
}}
|
||||
>
|
||||
{ts.isVisible("project_no") && (
|
||||
<TableCell style={ts.thStyle("project_no")}>
|
||||
<div className="flex items-center gap-1" style={{ paddingLeft: depth * 20 }}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-muted transition-transform"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(p.id); }}
|
||||
>
|
||||
<ChevronRight className={cn("w-3.5 h-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<span className={cn("font-semibold text-xs", depth === 0 ? "text-primary" : depth === 1 ? "text-primary/80" : "text-primary/60")}>
|
||||
{p.projectNo}
|
||||
</span>
|
||||
{p.relation && (
|
||||
<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", getRelationColor(p.relation))}>
|
||||
{getRelationLabel(p.relation)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("status") && (
|
||||
<TableCell className="text-center" style={ts.thStyle("status")}>
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getStatusColor(p.status))}>
|
||||
{p.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("name") && (
|
||||
<TableCell className="font-medium text-sm" style={ts.thStyle("name")}>
|
||||
{p.name}
|
||||
{childCount > 0 && (
|
||||
<Badge variant="outline" className="ml-1.5 text-[10px] py-0 px-1.5 font-normal">
|
||||
<FolderOpen className="w-3 h-3 mr-0.5" /> {childCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("pm") && <TableCell className="text-[13px]" style={ts.thStyle("pm")}>{p.pm}</TableCell>}
|
||||
{ts.isVisible("customer") && <TableCell className="text-[13px]" style={ts.thStyle("customer")}>{p.customer}</TableCell>}
|
||||
{ts.isVisible("start_date") && <TableCell className="text-[13px] text-muted-foreground" style={ts.thStyle("start_date")}>{p.startDate}</TableCell>}
|
||||
{ts.isVisible("end_date") && <TableCell className="text-[13px] text-muted-foreground" style={ts.thStyle("end_date")}>{p.endDate}</TableCell>}
|
||||
{ts.isVisible("progress") && (
|
||||
<TableCell style={ts.thStyle("progress")}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", progressColor(p.progress))} style={{ width: `${p.progress}%` }} />
|
||||
</div>
|
||||
<span className={cn("text-[11px] font-medium min-w-[28px] text-right", progressTextColor(p.progress))}>{p.progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("source_no") && <TableCell className="text-[13px] text-muted-foreground" style={ts.thStyle("source_no")}>{p.sourceNo || "-"}</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "projectNo", label: "프로젝트번호", width: "w-[160px]", render: (_val: any, row: any) => {
|
||||
const depth = row._depth ?? 0;
|
||||
const hasChildren = filteredProjects.some((c) => c.parentId === row.id);
|
||||
const isExpanded = expandedIds[row.id] !== false;
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={{ paddingLeft: depth * 20 }}>
|
||||
{hasChildren ? (
|
||||
<button className="p-0.5 rounded hover:bg-muted transition-transform" onClick={(e) => { e.stopPropagation(); toggleExpand(row.id); }}>
|
||||
<ChevronRight className={cn("w-3.5 h-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (<span className="w-4" />)}
|
||||
<span className={cn("font-semibold text-xs", depth === 0 ? "text-primary" : depth === 1 ? "text-primary/80" : "text-primary/60")}>{row.projectNo}</span>
|
||||
{row.relation && (<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", getRelationColor(row.relation))}>{getRelationLabel(row.relation)}</span>)}
|
||||
</div>
|
||||
);
|
||||
}},
|
||||
{ key: "status", label: "상태", width: "w-[80px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getStatusColor(val))}>{val}</span> },
|
||||
{ key: "name", label: "프로젝트명", width: "w-[200px]", render: (val: any, row: any) => {
|
||||
const childCount = getAllDescendants(projects, row.id).length;
|
||||
return (<><span className="font-medium text-sm">{val}</span>{childCount > 0 && <Badge variant="outline" className="ml-1.5 text-[10px] py-0 px-1.5 font-normal"><FolderOpen className="w-3 h-3 mr-0.5" /> {childCount}</Badge>}</>);
|
||||
}},
|
||||
{ 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) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", progressColor(val))} style={{ width: `${val}%` }} />
|
||||
</div>
|
||||
<span className={cn("text-[11px] font-medium min-w-[28px] text-right", progressTextColor(val))}>{val}%</span>
|
||||
</div>
|
||||
)},
|
||||
{ key: "sourceNo", label: "원접수번호", width: "w-[90px]", render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "-"}</span> },
|
||||
] 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -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() {
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"text-[11px] uppercase tracking-wide",
|
||||
col.key === "source_type" && "w-[60px] text-center",
|
||||
col.key === "request_no" && "w-[130px]",
|
||||
col.key === "status" && "w-[90px] text-center",
|
||||
col.key === "priority" && "w-[80px] text-center",
|
||||
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]",
|
||||
)}
|
||||
style={ts.thStyle(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && allTasks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length}>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="mb-2 h-10 w-10 animate-spin" />
|
||||
<span className="text-sm">로딩 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length}>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Inbox className="mb-2 h-10 w-10" />
|
||||
<span className="text-sm">조건에 맞는 업무가 없어요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredData.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors border-l-[3px]",
|
||||
selectedTaskId === item.dbId
|
||||
? "bg-primary/5 border-l-primary"
|
||||
: item.status === "신규접수"
|
||||
? "bg-primary/5 border-l-primary/40"
|
||||
: "border-l-transparent"
|
||||
)}
|
||||
onClick={() => handleSelectTask(item.dbId)}
|
||||
>
|
||||
{ts.isVisible("source_type") && (
|
||||
<TableCell className="text-center" style={ts.thStyle("source_type")}>
|
||||
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(item.sourceType))}>
|
||||
{item.sourceType === "dr" ? "DR" : "ECR"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("request_no") && (
|
||||
<TableCell className={cn("text-[13px] font-semibold", item.sourceType === "dr" ? "text-primary" : "text-secondary-foreground")} style={ts.thStyle("request_no")}>
|
||||
{item.id}
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("status") && (
|
||||
<TableCell className="text-center" style={ts.thStyle("status")}>
|
||||
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(item.status))}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("priority") && (
|
||||
<TableCell className="text-center" style={ts.thStyle("priority")}>
|
||||
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(item.priority))}>
|
||||
{item.priority}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("target_name") && <TableCell className="max-w-[200px] truncate text-[13px] font-medium" style={ts.thStyle("target_name")}>{item.targetName}</TableCell>}
|
||||
{ts.isVisible("req_dept") && <TableCell className="text-[13px]" style={ts.thStyle("req_dept")}>{item.reqDept}</TableCell>}
|
||||
{ts.isVisible("requester") && <TableCell className="text-[13px]" style={ts.thStyle("requester")}>{item.requester}</TableCell>}
|
||||
{ts.isVisible("request_date") && <TableCell className="text-[13px]" style={ts.thStyle("request_date")}>{item.date}</TableCell>}
|
||||
{ts.isVisible("due_date") && <TableCell className="text-[13px]" style={ts.thStyle("due_date")}>{item.dueDate}</TableCell>}
|
||||
{ts.isVisible("designer") && (
|
||||
<TableCell className="text-[13px]" style={ts.thStyle("designer")}>
|
||||
{item.designer || <span className="text-muted-foreground">미배정</span>}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<EDataTable<TaskItem>
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn<TaskItem> => ({
|
||||
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) => (
|
||||
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(row.sourceType))}>
|
||||
{row.sourceType === "dr" ? "DR" : "ECR"}
|
||||
</Badge>
|
||||
)
|
||||
: col.key === "request_no"
|
||||
? (val: any, row: TaskItem) => (
|
||||
<span className={cn("text-[13px] font-semibold", row.sourceType === "dr" ? "text-primary" : "text-secondary-foreground")}>
|
||||
{val}
|
||||
</span>
|
||||
)
|
||||
: col.key === "status"
|
||||
? (val: any) => (
|
||||
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(val))}>
|
||||
{val}
|
||||
</Badge>
|
||||
)
|
||||
: col.key === "priority"
|
||||
? (val: any) => (
|
||||
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(val))}>
|
||||
{val}
|
||||
</Badge>
|
||||
)
|
||||
: col.key === "designer"
|
||||
? (val: any) => val ? <span>{val}</span> : <span className="text-muted-foreground">미배정</span>
|
||||
: undefined,
|
||||
}))}
|
||||
data={ts.groupData(filteredData)}
|
||||
loading={loading}
|
||||
emptyMessage="조건에 맞는 업무가 없어요"
|
||||
rowKey={(row) => row.dbId}
|
||||
selectedId={selectedTaskId}
|
||||
onSelect={(id) => handleSelectTask(id ?? "")}
|
||||
draggableColumns={false}
|
||||
showPagination={false}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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<EDataTableColumn[]>(() => {
|
||||
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() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{equipLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : equipments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">등록된 설비가 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper style={{ tableLayout: "fixed" }}>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
{ts.isVisible("equipment_code") && <TableHead style={ts.thStyle("equipment_code")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비코드</TableHead>}
|
||||
{ts.isVisible("equipment_name") && <TableHead style={ts.thStyle("equipment_name")} className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비명</TableHead>}
|
||||
{ts.isVisible("equipment_type") && <TableHead style={ts.thStyle("equipment_type")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비유형</TableHead>}
|
||||
{ts.isVisible("manufacturer") && <TableHead style={ts.thStyle("manufacturer")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>}
|
||||
{ts.isVisible("installation_location") && <TableHead style={ts.thStyle("installation_location")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설치장소</TableHead>}
|
||||
{ts.isVisible("operation_status") && <TableHead style={ts.thStyle("operation_status")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">가동상태</TableHead>}
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{equipments.map((equip) => (
|
||||
<TableRow
|
||||
key={equip.id}
|
||||
className={cn("cursor-pointer", selectedEquipId === equip.id && "border-l-2 border-l-primary bg-primary/[0.08]")}
|
||||
onClick={() => setSelectedEquipId(equip.id)}
|
||||
onDoubleClick={openEquipEdit}
|
||||
>
|
||||
{ts.isVisible("equipment_code") && <TableCell style={ts.thStyle("equipment_code")} className="text-[13px] font-mono">{equip.equipment_code}</TableCell>}
|
||||
{ts.isVisible("equipment_name") && <TableCell style={ts.thStyle("equipment_name")} className="text-sm max-w-[150px] truncate" title={equip.equipment_name}>{equip.equipment_name || "-"}</TableCell>}
|
||||
{ts.isVisible("equipment_type") && <TableCell style={ts.thStyle("equipment_type")} className="text-[13px]">{equip.equipment_type || "-"}</TableCell>}
|
||||
{ts.isVisible("manufacturer") && <TableCell style={ts.thStyle("manufacturer")} className="text-[13px]">{equip.manufacturer || "-"}</TableCell>}
|
||||
{ts.isVisible("installation_location") && <TableCell style={ts.thStyle("installation_location")} className="text-[13px]">{equip.installation_location || "-"}</TableCell>}
|
||||
{ts.isVisible("operation_status") && <TableCell style={ts.thStyle("operation_status")} className="text-[13px]">{equip.operation_status || "-"}</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={mainTableColumns}
|
||||
data={ts.groupData(equipments)}
|
||||
loading={equipLoading}
|
||||
emptyMessage="등록된 설비가 없어요"
|
||||
selectedId={selectedEquipId}
|
||||
onSelect={(id) => setSelectedEquipId(id)}
|
||||
onRowDoubleClick={() => openEquipEdit()}
|
||||
showPagination={true}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-equipment-info-main"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={datatypes.length > 0 && dtChecked.length === datatypes.length}
|
||||
onCheckedChange={(v) => setDtChecked(v ? datatypes.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dtLoading ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : datatypes.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 PLC 데이터타입이 없어요</p></TableCell></TableRow>
|
||||
) : datatypes.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", dtChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setDtChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openDtEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={dtChecked.includes(row.id)} onCheckedChange={(v) => setDtChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className={col.key === "is_active" ? "text-center" : ""}>
|
||||
{col.key === "is_active"
|
||||
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
||||
: row[col.key] ?? ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.key === "is_active" ? "center" : undefined,
|
||||
render: col.key === "is_active"
|
||||
? (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge>
|
||||
: undefined,
|
||||
}))}
|
||||
data={ts.groupData(datatypes)}
|
||||
loading={dtLoading}
|
||||
emptyMessage="등록된 PLC 데이터타입이 없어요"
|
||||
showCheckbox
|
||||
checkedIds={dtChecked}
|
||||
onCheckedChange={setDtChecked}
|
||||
onRowDoubleClick={(row) => openDtEdit(row)}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -360,52 +340,26 @@ export default function PlcSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={configs.length > 0 && cfgChecked.length === configs.length}
|
||||
onCheckedChange={(v) => setCfgChecked(v ? configs.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설정명</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스연결ID</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스테이블</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">대상테이블</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수집유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">스케줄(Cron)</TableHead>
|
||||
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cfgLoading ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : configs.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 수집 설정이 없어요</p></TableCell></TableRow>
|
||||
) : configs.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", cfgChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setCfgChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openCfgEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={cfgChecked.includes(row.id)} onCheckedChange={(v) => setCfgChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
<TableCell>{row.config_name}</TableCell>
|
||||
<TableCell>{row.source_connection_id}</TableCell>
|
||||
<TableCell>{row.source_table}</TableCell>
|
||||
<TableCell>{row.target_table}</TableCell>
|
||||
<TableCell>{row.collection_type}</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{row.schedule_cron}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "config_name", label: "설정명" },
|
||||
{ key: "source_connection_id", label: "소스연결ID", width: "w-[110px]" },
|
||||
{ key: "source_table", label: "소스테이블", width: "w-[120px]" },
|
||||
{ key: "target_table", label: "대상테이블", width: "w-[120px]" },
|
||||
{ key: "collection_type", label: "수집유형", width: "w-[90px]" },
|
||||
{ key: "schedule_cron", label: "스케줄(Cron)", width: "w-[120px]", render: (val: any) => <span className="font-mono text-[13px]">{val}</span> },
|
||||
{ key: "is_active", label: "사용여부", width: "w-[80px]", align: "center" as const, render: (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge> },
|
||||
] as EDataTableColumn[]}
|
||||
data={configs}
|
||||
loading={cfgLoading}
|
||||
emptyMessage="등록된 수집 설정이 없어요"
|
||||
showCheckbox
|
||||
checkedIds={cfgChecked}
|
||||
onCheckedChange={setCfgChecked}
|
||||
onRowDoubleClick={(row) => openCfgEdit(row)}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{tabLoading[tab.key] ? (
|
||||
<div className="flex h-40 items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">불러오는 중...</span>
|
||||
</div>
|
||||
) : displayData.length === 0 ? (
|
||||
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted/50">
|
||||
<Inbox className="h-6 w-6 opacity-40" />
|
||||
</div>
|
||||
<span className="text-sm">등록된 {tab.label} 정보가 없어요</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
등록 버튼을 눌러 새 항목을 추가해주세요
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] p-2">
|
||||
<Checkbox
|
||||
checked={isAllChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAllCheck(tab.key, !!checked)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
{getVisibleColumns(tab.key).map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center"
|
||||
)}
|
||||
style={tsMap[tab.key].thStyle(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayData.map((row: any, idx: number) => {
|
||||
const rowId = String(row.id);
|
||||
const isChecked = tabChecked[tab.key].includes(rowId);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id ?? idx}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors hover:bg-accent/50",
|
||||
isChecked && "bg-primary/5 hover:bg-primary/10"
|
||||
)}
|
||||
onClick={() => toggleRowCheck(tab.key, rowId)}
|
||||
onDoubleClick={() => handleOpenEdit(row)}
|
||||
>
|
||||
<TableCell className="w-[40px] p-2">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRowCheck(tab.key, rowId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
{getVisibleColumns(tab.key).map((col) => {
|
||||
const val = row[col.key];
|
||||
const display =
|
||||
col.formatNumber && val != null && val !== ""
|
||||
? Number(val).toLocaleString()
|
||||
: val ?? "";
|
||||
return (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"max-w-[240px] truncate p-2 text-sm",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center"
|
||||
)}
|
||||
style={tsMap[tab.key].thStyle(col.key)}
|
||||
>
|
||||
{display}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<EDataTable
|
||||
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
<span className="font-mono">
|
||||
<span className={cn(row._isLow && "text-destructive font-bold")}>
|
||||
{Number(row.current_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
{row._isLow && (
|
||||
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (col.key === "safety_qty") {
|
||||
return {
|
||||
...base,
|
||||
align: "right" as const,
|
||||
formatNumber: true,
|
||||
};
|
||||
}
|
||||
if (col.key === "status") {
|
||||
return {
|
||||
...base,
|
||||
render: (val: any) => (
|
||||
<Badge variant={getStatusVariant(val)} className="text-[10px]">
|
||||
{val}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExcelExport = () => {
|
||||
if (stockItems.length === 0) {
|
||||
@@ -377,86 +417,19 @@ export default function InventoryStatusPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{stockLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : stockItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
||||
등록된 재고가 없어요
|
||||
</div>
|
||||
) : (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={ts.thStyle(col.key)}
|
||||
className={cn(col.align === "right" && "text-right")}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stockItems.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs",
|
||||
selectedStockId === item.id && "bg-primary/10",
|
||||
item._isLow && "bg-destructive/5"
|
||||
)}
|
||||
onClick={() => setSelectedStockId(item.id)}
|
||||
>
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "current_qty") {
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-right font-mono">
|
||||
<span className={cn(item._isLow && "text-destructive font-bold")}>
|
||||
{Number(item.current_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
{item._isLow && (
|
||||
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "safety_qty") {
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-right font-mono">
|
||||
{Number(item.safety_qty || 0).toLocaleString()}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "status") {
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
||||
<Badge variant={getStatusVariant(item.status)} className="text-[10px]">
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="truncate max-w-[150px]">
|
||||
{item[col.key] ?? ""}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={stockColumns}
|
||||
data={ts.groupData(stockItems)}
|
||||
rowKey={(row) => row.id}
|
||||
loading={stockLoading}
|
||||
emptyMessage="등록된 재고가 없어요"
|
||||
selectedId={selectedStockId}
|
||||
onSelect={(id) => setSelectedStockId(id)}
|
||||
showRowNumber
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-inventory"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px] uppercase tracking-wide">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={toggleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.isVisible("outbound_number") && <TableHead style={ts.thStyle("outbound_number")} className="w-[130px] text-[11px] uppercase tracking-wide">출고번호</TableHead>}
|
||||
{ts.isVisible("outbound_type") && <TableHead style={ts.thStyle("outbound_type")} className="w-[90px] text-[11px] uppercase tracking-wide">출고유형</TableHead>}
|
||||
{ts.isVisible("outbound_date") && <TableHead style={ts.thStyle("outbound_date")} className="w-[100px] text-[11px] uppercase tracking-wide">출고일</TableHead>}
|
||||
{ts.isVisible("reference_number") && <TableHead style={ts.thStyle("reference_number")} className="w-[120px] text-[11px] uppercase tracking-wide">참조번호</TableHead>}
|
||||
{ts.isVisible("source_type") && <TableHead style={ts.thStyle("source_type")} className="w-[80px] text-[11px] uppercase tracking-wide">데이터출처</TableHead>}
|
||||
{ts.isVisible("customer_name") && <TableHead style={ts.thStyle("customer_name")} className="w-[120px] text-[11px] uppercase tracking-wide">거래처</TableHead>}
|
||||
{ts.isVisible("item_number") && <TableHead style={ts.thStyle("item_number")} className="w-[100px] text-[11px] uppercase tracking-wide">품목코드</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="min-w-[150px] text-[11px] uppercase tracking-wide">품목명</TableHead>}
|
||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="w-[80px] text-[11px] uppercase tracking-wide">규격</TableHead>}
|
||||
{ts.isVisible("outbound_qty") && <TableHead style={ts.thStyle("outbound_qty")} className="w-[80px] text-right text-[11px] uppercase tracking-wide">출고수량</TableHead>}
|
||||
{ts.isVisible("unit_price") && <TableHead style={ts.thStyle("unit_price")} className="w-[90px] text-right text-[11px] uppercase tracking-wide">단가</TableHead>}
|
||||
{ts.isVisible("total_amount") && <TableHead style={ts.thStyle("total_amount")} className="w-[100px] text-right text-[11px] uppercase tracking-wide">금액</TableHead>}
|
||||
{ts.isVisible("warehouse_name") && <TableHead style={ts.thStyle("warehouse_name")} className="w-[100px] text-[11px] uppercase tracking-wide">창고</TableHead>}
|
||||
{ts.isVisible("outbound_status") && <TableHead style={ts.thStyle("outbound_status")} className="w-[90px] text-center text-[11px] uppercase tracking-wide">출고상태</TableHead>}
|
||||
{ts.isVisible("remark") && <TableHead style={ts.thStyle("remark")} className="w-[100px] text-[11px] uppercase tracking-wide">비고</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={16} className="h-40 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={16}
|
||||
className="h-40 text-center"
|
||||
>
|
||||
<div className="mx-auto flex max-w-xs flex-col items-center gap-2 rounded-lg border border-dashed border-border p-8">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">등록된 출고 내역이 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
출고 등록 버튼을 클릭하여 출고를 추가해주세요
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
checkedIds.includes(row.id) && "bg-primary/5"
|
||||
)}
|
||||
onClick={() => toggleCheck(row.id)}
|
||||
onDoubleClick={() => openEditModal(row)}
|
||||
>
|
||||
<TableCell
|
||||
className="text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(row.id)}
|
||||
onCheckedChange={() => toggleCheck(row.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
{ts.isVisible("outbound_number") && <TableCell style={ts.thStyle("outbound_number")} className="max-w-[130px] truncate font-medium" title={row.outbound_number}>
|
||||
{row.outbound_number}
|
||||
</TableCell>}
|
||||
{ts.isVisible("outbound_type") && <TableCell style={ts.thStyle("outbound_type")}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[11px]", getTypeColor(row.outbound_type))}
|
||||
>
|
||||
{row.outbound_type || "-"}
|
||||
</Badge>
|
||||
</TableCell>}
|
||||
{ts.isVisible("outbound_date") && <TableCell style={ts.thStyle("outbound_date")} className="text-[13px]">
|
||||
{row.outbound_date
|
||||
? new Date(row.outbound_date).toLocaleDateString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("reference_number") && <TableCell style={ts.thStyle("reference_number")} className="max-w-[120px] truncate text-[13px]" title={row.reference_number || "-"}>
|
||||
{row.reference_number || "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("source_type") && <TableCell style={ts.thStyle("source_type")} className="text-[13px]">
|
||||
{row.source_type
|
||||
? SOURCE_TYPE_LABEL[row.source_type] || row.source_type
|
||||
: "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="max-w-[120px] truncate text-[13px]" title={row.customer_name || "-"}>
|
||||
{row.customer_name || "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_number") && <TableCell style={ts.thStyle("item_number")} className="max-w-[130px] truncate text-[13px]" title={row.item_code || "-"}>
|
||||
{row.item_code || "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="max-w-[150px] truncate text-[13px]" title={row.item_name || "-"}>{row.item_name || "-"}</TableCell>}
|
||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="max-w-[100px] truncate text-[13px]" title={row.specification || "-"}>{row.specification || "-"}</TableCell>}
|
||||
{ts.isVisible("outbound_qty") && <TableCell style={ts.thStyle("outbound_qty")} className="text-right font-mono text-[13px] font-semibold">
|
||||
{Number(row.outbound_qty || 0).toLocaleString()}
|
||||
</TableCell>}
|
||||
{ts.isVisible("unit_price") && <TableCell style={ts.thStyle("unit_price")} className="text-right font-mono text-[13px]">
|
||||
{Number(row.unit_price || 0).toLocaleString()}
|
||||
</TableCell>}
|
||||
{ts.isVisible("total_amount") && <TableCell style={ts.thStyle("total_amount")} className="text-right font-mono text-[13px] font-semibold">
|
||||
{Number(row.total_amount || 0).toLocaleString()}
|
||||
</TableCell>}
|
||||
{ts.isVisible("warehouse_name") && <TableCell style={ts.thStyle("warehouse_name")} className="text-[13px]">
|
||||
{row.warehouse_name || row.warehouse_code || "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("outbound_status") && <TableCell style={ts.thStyle("outbound_status")} className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
getStatusColor(row.outbound_status)
|
||||
)}
|
||||
>
|
||||
{row.outbound_status || "-"}
|
||||
</Badge>
|
||||
</TableCell>}
|
||||
{ts.isVisible("remark") && <TableCell style={ts.thStyle("remark")} className="max-w-[120px] truncate text-[13px]" title={row.memo || "-"}>
|
||||
{row.memo || "-"}
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "outbound_number", label: "출고번호", width: "w-[130px]" },
|
||||
{ key: "outbound_type", label: "출고유형", width: "w-[90px]", render: (v) => (
|
||||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(v))}>{v || "-"}</Badge>
|
||||
)},
|
||||
{ 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) => (
|
||||
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(v))}>{v || "-"}</Badge>
|
||||
)},
|
||||
{ key: "memo", label: "비고", width: "w-[100px]" },
|
||||
] as EDataTableColumn<OutboundItem>[]}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 출고 등록 모달 */}
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
|
||||
{/* 포장재 목록 테이블 */}
|
||||
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{ts.isVisible("pkg_code") && <TableHead style={ts.thStyle("pkg_code")} className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>}
|
||||
{ts.isVisible("pkg_name") && <TableHead style={ts.thStyle("pkg_name")} className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">포장명</TableHead>}
|
||||
{ts.isVisible("pkg_type") && <TableHead style={ts.thStyle("pkg_type")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">유형</TableHead>}
|
||||
{ts.isVisible("size") && <TableHead style={ts.thStyle("size")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">크기(mm)</TableHead>}
|
||||
{ts.isVisible("max_weight") && <TableHead style={ts.thStyle("max_weight")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">최대중량</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[60px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pkgLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredPkgUnits.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-40 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/20">
|
||||
<Inbox className="h-6 w-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">등록된 포장재가 없어요</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredPkgUnits.map((p) => (
|
||||
<TableRow
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs transition-colors",
|
||||
selectedPkg?.id === p.id ? "bg-primary/5" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => selectPkg(p)}
|
||||
>
|
||||
{ts.isVisible("pkg_code") && <TableCell style={ts.thStyle("pkg_code")} className="p-2 font-medium">{p.pkg_code}</TableCell>}
|
||||
{ts.isVisible("pkg_name") && <TableCell style={ts.thStyle("pkg_name")} className="p-2">{p.pkg_name}</TableCell>}
|
||||
{ts.isVisible("pkg_type") && <TableCell style={ts.thStyle("pkg_type")} className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>}
|
||||
{ts.isVisible("size") && <TableCell style={ts.thStyle("size")} className="p-2 text-[10px] tabular-nums">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>}
|
||||
{ts.isVisible("max_weight") && <TableCell style={ts.thStyle("max_weight")} className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="p-2 text-center">
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(p.status))}>
|
||||
{STATUS_LABEL[p.status] || p.status}
|
||||
</span>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "pkg_code", label: "품목코드" },
|
||||
{ key: "pkg_name", label: "포장명" },
|
||||
{ key: "pkg_type", label: "유형", width: "w-[80px]", render: (v) => 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) => (
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
|
||||
{STATUS_LABEL[v] || v}
|
||||
</span>
|
||||
)},
|
||||
] as EDataTableColumn<PkgUnit>[]}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 매칭 품목 서브패널 */}
|
||||
|
||||
@@ -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() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={toggleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.isVisible("inbound_number") && <TableHead style={ts.thStyle("inbound_number")} className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고번호</TableHead>}
|
||||
{ts.isVisible("inbound_type") && <TableHead style={ts.thStyle("inbound_type")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>}
|
||||
{ts.isVisible("inbound_date") && <TableHead style={ts.thStyle("inbound_date")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고일</TableHead>}
|
||||
{ts.isVisible("reference_number") && <TableHead style={ts.thStyle("reference_number")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>}
|
||||
{ts.isVisible("source_type") && <TableHead style={ts.thStyle("source_type")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">데이터출처</TableHead>}
|
||||
{ts.isVisible("supplier_name") && <TableHead style={ts.thStyle("supplier_name")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급처</TableHead>}
|
||||
{ts.isVisible("item_number") && <TableHead style={ts.thStyle("item_number")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{ts.isVisible("inbound_qty") && <TableHead style={ts.thStyle("inbound_qty")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>}
|
||||
{ts.isVisible("unit_price") && <TableHead style={ts.thStyle("unit_price")} className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>}
|
||||
{ts.isVisible("total_amount") && <TableHead style={ts.thStyle("total_amount")} className="w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>}
|
||||
{ts.isVisible("warehouse_name") && <TableHead style={ts.thStyle("warehouse_name")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">창고</TableHead>}
|
||||
{ts.isVisible("inbound_status") && <TableHead style={ts.thStyle("inbound_status")} className="w-[90px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고상태</TableHead>}
|
||||
{ts.isVisible("remark") && <TableHead style={ts.thStyle("remark")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={16} className="h-40 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={16}
|
||||
className="h-40 text-center"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
|
||||
<Inbox className="h-10 w-10 opacity-30" />
|
||||
<p className="text-sm font-medium">등록된 입고 내역이 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
입고 등록 버튼을 클릭하여 입고를 추가해 보세요
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
checkedIds.includes(row.id) && "bg-primary/5"
|
||||
)}
|
||||
onClick={() => toggleCheck(row.id)}
|
||||
>
|
||||
<TableCell
|
||||
className="text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(row.id)}
|
||||
onCheckedChange={() => toggleCheck(row.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
{ts.isVisible("inbound_number") && <TableCell style={ts.thStyle("inbound_number")} className="max-w-[130px] truncate font-medium" title={row.inbound_number}>
|
||||
{row.inbound_number}
|
||||
</TableCell>}
|
||||
{ts.isVisible("inbound_type") && <TableCell style={ts.thStyle("inbound_type")}>
|
||||
<Badge
|
||||
variant={getTypeVariant(row.inbound_type)}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{row.inbound_type || "-"}
|
||||
</Badge>
|
||||
</TableCell>}
|
||||
{ts.isVisible("inbound_date") && <TableCell style={ts.thStyle("inbound_date")} className="text-[13px]">
|
||||
{row.inbound_date
|
||||
? new Date(row.inbound_date).toLocaleDateString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("reference_number") && <TableCell style={ts.thStyle("reference_number")} className="max-w-[120px] truncate text-[13px]" title={row.reference_number || "-"}>
|
||||
{row.reference_number || "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("source_type") && <TableCell style={ts.thStyle("source_type")} className="text-[13px]">
|
||||
{row.source_table
|
||||
? SOURCE_TABLE_LABEL[row.source_table] || row.source_table
|
||||
: "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("supplier_name") && <TableCell style={ts.thStyle("supplier_name")} className="max-w-[120px] truncate text-[13px]" title={row.supplier_name || "-"}>
|
||||
{row.supplier_name || "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_number") && <TableCell style={ts.thStyle("item_number")} className="max-w-[130px] truncate text-[13px]" title={row.item_number || "-"}>
|
||||
{row.item_number || "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="max-w-[150px] truncate text-[13px]" title={row.item_name || "-"}>{row.item_name || "-"}</TableCell>}
|
||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="max-w-[100px] truncate text-[13px]" title={row.spec || "-"}>{row.spec || "-"}</TableCell>}
|
||||
{ts.isVisible("inbound_qty") && <TableCell style={ts.thStyle("inbound_qty")} className="text-right text-[13px] font-semibold">
|
||||
{Number(row.inbound_qty || 0).toLocaleString()}
|
||||
</TableCell>}
|
||||
{ts.isVisible("unit_price") && <TableCell style={ts.thStyle("unit_price")} className="text-right text-[13px]">
|
||||
{Number(row.unit_price || 0).toLocaleString()}
|
||||
</TableCell>}
|
||||
{ts.isVisible("total_amount") && <TableCell style={ts.thStyle("total_amount")} className="text-right text-[13px] font-semibold">
|
||||
{Number(row.total_amount || 0).toLocaleString()}
|
||||
</TableCell>}
|
||||
{ts.isVisible("warehouse_name") && <TableCell style={ts.thStyle("warehouse_name")} className="text-[13px]">
|
||||
{row.warehouse_name || row.warehouse_code || "-"}
|
||||
</TableCell>}
|
||||
{ts.isVisible("inbound_status") && <TableCell style={ts.thStyle("inbound_status")} className="text-center">
|
||||
<Badge
|
||||
variant={getStatusVariant(row.inbound_status)}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{row.inbound_status || "-"}
|
||||
</Badge>
|
||||
</TableCell>}
|
||||
{ts.isVisible("remark") && <TableCell style={ts.thStyle("remark")} className="max-w-[120px] truncate text-[13px]" title={row.memo || "-"}>
|
||||
{row.memo || "-"}
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "inbound_number", label: "입고번호", width: "w-[130px]" },
|
||||
{ key: "inbound_type", label: "입고유형", width: "w-[90px]", render: (v) => (
|
||||
<Badge variant={getTypeVariant(v)} className="text-[11px]">{v || "-"}</Badge>
|
||||
)},
|
||||
{ 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) => (
|
||||
<Badge variant={getStatusVariant(v)} className="text-[11px]">{v || "-"}</Badge>
|
||||
)},
|
||||
{ key: "memo", label: "비고", width: "w-[100px]" },
|
||||
] as EDataTableColumn<InboundItem>[]}
|
||||
data={ts.groupData(data)}
|
||||
rowKey={(row) => row.id}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 입고 내역이 없어요"
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
showPagination
|
||||
draggableColumns
|
||||
columnOrderKey="c16-receiving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 입고 등록 모달 */}
|
||||
|
||||
@@ -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) => (
|
||||
<Badge variant={getTypeVariant(val)} className="text-[10px]">
|
||||
{val}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (col.key === "status") {
|
||||
return {
|
||||
...base,
|
||||
render: (val: any) => (
|
||||
<Badge variant={getStatusVariant(val)} className="text-[10px]">
|
||||
{val}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExcelExport = () => {
|
||||
if (warehouses.length === 0) {
|
||||
@@ -775,70 +802,20 @@ export default function WarehouseManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{warehouseLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : warehouses.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
||||
등록된 창고가 없어요
|
||||
</div>
|
||||
) : (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{warehouses.map((w, idx) => (
|
||||
<TableRow
|
||||
key={w.id}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs",
|
||||
selectedWarehouseId === w.id && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => setSelectedWarehouseId(w.id)}
|
||||
onDoubleClick={() => openWarehouseEditModal(w)}
|
||||
>
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "warehouse_type") {
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
||||
<Badge variant={getTypeVariant(w.warehouse_type)} className="text-[10px]">
|
||||
{w.warehouse_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "status") {
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
||||
<Badge variant={getStatusVariant(w.status)} className="text-[10px]">
|
||||
{w.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="truncate max-w-[150px]">
|
||||
{w[col.key] ?? ""}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={warehouseColumns}
|
||||
data={ts.groupData(warehouses)}
|
||||
rowKey={(row) => row.id}
|
||||
loading={warehouseLoading}
|
||||
emptyMessage="등록된 창고가 없어요"
|
||||
selectedId={selectedWarehouseId}
|
||||
onSelect={(id) => setSelectedWarehouseId(id)}
|
||||
onRowDoubleClick={(row) => openWarehouseEditModal(row)}
|
||||
showRowNumber
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-warehouse"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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) => <span className="text-[13px]">{val || "-"}</span> },
|
||||
{ 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) => <span>{val || "-"}</span> },
|
||||
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]", render: (val: any) => <span>{val || "-"}</span> },
|
||||
{ key: "email", label: "이메일" },
|
||||
];
|
||||
|
||||
/* ── 트리 렌더 ── */
|
||||
const renderTree = (nodes: DeptNode[], depth = 0) => {
|
||||
return nodes.map((node) => {
|
||||
@@ -685,47 +696,17 @@ export default function CompanyPage() {
|
||||
)}
|
||||
</div>
|
||||
{selectedDeptCode ? (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{memberLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Users className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm">소속 사원이 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사번</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이름</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용자ID</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">직급</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">휴대폰</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이메일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((row) => (
|
||||
<TableRow
|
||||
key={row.user_id || row.id}
|
||||
className="cursor-pointer"
|
||||
onDoubleClick={() => openUserModal(row)}
|
||||
>
|
||||
<TableCell className="text-[13px]">{row.sabun || "-"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{row.user_name}</TableCell>
|
||||
<TableCell className="text-[13px] font-mono">{row.user_id}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.position_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.cell_phone || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.email || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={companyMemberColumns}
|
||||
data={members}
|
||||
rowKey={(row) => row.user_id || row.id}
|
||||
loading={memberLoading}
|
||||
emptyMessage="소속 사원이 없어요"
|
||||
emptyIcon={<Users className="w-8 h-8 mb-2" />}
|
||||
onRowDoubleClick={(row) => openUserModal(row)}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Users className="w-10 h-10 mb-3" />
|
||||
|
||||
@@ -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) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
|
||||
}]
|
||||
: []),
|
||||
...(isColVisible("status")
|
||||
? [{
|
||||
key: "status",
|
||||
label: "상태",
|
||||
width: "w-[70px]",
|
||||
render: (val: any) =>
|
||||
val ? (
|
||||
<Badge
|
||||
variant={val === "active" ? "default" : "outline"}
|
||||
className="text-[10px] px-1.5 py-0 h-5"
|
||||
>
|
||||
{val === "active" ? "활성" : (val || "\u2014")}
|
||||
</Badge>
|
||||
) : null,
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-4">
|
||||
{/* 검색 필터 바 */}
|
||||
@@ -366,61 +397,20 @@ export default function DepartmentPage() {
|
||||
</div>
|
||||
|
||||
{/* 부서 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table noWrapper style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">부서코드</TableHead>
|
||||
<TableHead className="min-w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">부서명</TableHead>
|
||||
{isColVisible("parent_dept_code") && <TableHead style={ts.thStyle("parent_dept_code")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상위부서</TableHead>}
|
||||
{isColVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deptLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : depts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground text-sm">
|
||||
등록된 부서가 없어요
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : depts.map((dept, idx) => (
|
||||
<TableRow
|
||||
key={dept.id}
|
||||
className={cn(
|
||||
"cursor-pointer select-none",
|
||||
selectedDeptId === dept.id ? "bg-primary/10 hover:bg-primary/10" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => setSelectedDeptId((prev) => prev === dept.id ? null : dept.id)}
|
||||
onDoubleClick={openDeptEdit}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-mono text-muted-foreground">{dept.dept_code}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{dept.dept_name}</TableCell>
|
||||
{isColVisible("parent_dept_code") && <TableCell style={ts.thStyle("parent_dept_code")} className="text-[13px] text-muted-foreground">{dept.parent_dept_code || "—"}</TableCell>}
|
||||
{isColVisible("status") && (
|
||||
<TableCell style={ts.thStyle("status")} className="text-[13px]">
|
||||
{dept.status && (
|
||||
<Badge
|
||||
variant={dept.status === "active" ? "default" : "outline"}
|
||||
className="text-[10px] px-1.5 py-0 h-5"
|
||||
>
|
||||
{dept.status === "active" ? "활성" : (dept.status || "—")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={deptColumns}
|
||||
data={ts.groupData(depts)}
|
||||
rowKey={(row) => row.id}
|
||||
loading={deptLoading}
|
||||
emptyMessage="등록된 부서가 없어요"
|
||||
selectedId={selectedDeptId}
|
||||
onSelect={(id) => setSelectedDeptId(id)}
|
||||
onRowDoubleClick={() => openDeptEdit()}
|
||||
showRowNumber
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-department"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
등록된 품목이 없어요
|
||||
</div>
|
||||
) : (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={ts.thStyle(col.key)}
|
||||
className={cn(
|
||||
"whitespace-nowrap text-xs",
|
||||
col.align === "right" && "text-right"
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id ?? idx}
|
||||
className={cn(
|
||||
"cursor-pointer text-sm",
|
||||
selectedId === item.id && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
onDoubleClick={() => openEditModal(item)}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground">{idx + 1}</TableCell>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
style={ts.thStyle(col.key)}
|
||||
className={cn(
|
||||
"whitespace-nowrap max-w-[160px] truncate",
|
||||
col.align === "right" && "text-right tabular-nums"
|
||||
)}
|
||||
>
|
||||
{item[col.key] ?? ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function OptionsSettingPage() {
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes("__") ? selectedColumn.split("__").pop()! : selectedColumn}
|
||||
columnName={selectedColumn.includes(".") ? selectedColumn.split(".").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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<EDataTableColumn[]>(() => {
|
||||
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() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{itemLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">등록된 외주품목이 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper style={{ tableLayout: "fixed" }}>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
{ts.isVisible("item_number") && <TableHead style={ts.thStyle("item_number")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
||||
{ts.isVisible("size") && <TableHead style={ts.thStyle("size")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{ts.isVisible("unit") && <TableHead style={ts.thStyle("unit")} className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>}
|
||||
{ts.isVisible("standard_price") && <TableHead style={ts.thStyle("standard_price")} className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준단가</TableHead>}
|
||||
{ts.isVisible("selling_price") && <TableHead style={ts.thStyle("selling_price")} className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">판매가격</TableHead>}
|
||||
{ts.isVisible("currency_code") && <TableHead style={ts.thStyle("currency_code")} className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn("cursor-pointer", selectedItemId === item.id && "border-l-2 border-l-primary bg-primary/[0.08]")}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
onDoubleClick={openEditItem}
|
||||
>
|
||||
{ts.isVisible("item_number") && <TableCell style={ts.thStyle("item_number")} className="text-[13px] font-mono">{item.item_number}</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="text-sm max-w-[150px] truncate" title={item.item_name}>{item.item_name || "-"}</TableCell>}
|
||||
{ts.isVisible("size") && <TableCell style={ts.thStyle("size")} className="text-[13px]">{item.size || "-"}</TableCell>}
|
||||
{ts.isVisible("unit") && <TableCell style={ts.thStyle("unit")} className="text-[13px]">{item.unit || "-"}</TableCell>}
|
||||
{ts.isVisible("standard_price") && <TableCell style={ts.thStyle("standard_price")} className="text-[13px] text-right font-mono">{formatNum(item.standard_price)}</TableCell>}
|
||||
{ts.isVisible("selling_price") && <TableCell style={ts.thStyle("selling_price")} className="text-[13px] text-right font-mono">{formatNum(item.selling_price)}</TableCell>}
|
||||
{ts.isVisible("currency_code") && <TableCell style={ts.thStyle("currency_code")} className="text-[13px]">{item.currency_code || "-"}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-[13px]">{item.status || "-"}</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={mainTableColumns}
|
||||
data={ts.groupData(items)}
|
||||
loading={itemLoading}
|
||||
emptyMessage="등록된 외주품목이 없어요"
|
||||
selectedId={selectedItemId}
|
||||
onSelect={(id) => setSelectedItemId(id)}
|
||||
onRowDoubleClick={() => openEditItem()}
|
||||
showPagination={true}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-subcontractor-item-main"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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<EDataTableColumn[]>(() => {
|
||||
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() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{subcontractorLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : subcontractors.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Wrench className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="text-sm font-medium">등록된 외주업체가 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subcontractors.map((sub) => (
|
||||
<TableRow
|
||||
key={sub.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selectedSubcontractorId === sub.id
|
||||
? "bg-primary/10 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => setSelectedSubcontractorId(sub.id)}
|
||||
onDoubleClick={openSubcontractorEdit}
|
||||
>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableCell key={col.key} className="text-[13px]">
|
||||
{renderCellValue(sub, col.key)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={mainTableColumns}
|
||||
data={ts.groupData(subcontractors)}
|
||||
loading={subcontractorLoading}
|
||||
emptyMessage="등록된 외주업체가 없어요"
|
||||
selectedId={selectedSubcontractorId}
|
||||
onSelect={(id) => setSelectedSubcontractorId(id)}
|
||||
onRowDoubleClick={() => openSubcontractorEdit()}
|
||||
showPagination={true}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-subcontractor-main"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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 목록 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : bomList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<Inbox className="w-8 h-8 text-muted-foreground/40" />
|
||||
<p className="text-xs">등록된 BOM이 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[36px] text-center">
|
||||
<Checkbox
|
||||
checked={checkedIds.length === bomList.length && bomList.length > 0}
|
||||
onCheckedChange={(checked) =>
|
||||
setCheckedIds(checked ? bomList.map((r) => r.id) : [])
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomList.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedBomId === row.id
|
||||
? "bg-primary/10 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => setSelectedBomId(row.id)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(row.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setCheckedIds((prev) =>
|
||||
checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "item_code") {
|
||||
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="font-mono text-[13px]">{row.item_code || row.item_number || "-"}</TableCell>;
|
||||
}
|
||||
if (col.key === "bom_type") {
|
||||
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px]">{BOM_TYPE_OPTIONS.find((o) => o.code === row.bom_type)?.label || row.bom_type || "-"}</TableCell>;
|
||||
}
|
||||
if (col.key === "status") {
|
||||
return <TableCell key={col.key} style={ts.thStyle(col.key)}>{renderStatusBadge(row.status)}</TableCell>;
|
||||
}
|
||||
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] max-w-[160px] truncate">{row[col.key] || "-"}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
render: col.key === "item_code"
|
||||
? (_val: any, row: any) => <span className="font-mono text-[13px]">{row.item_code || row.item_number || "-"}</span>
|
||||
: col.key === "bom_type"
|
||||
? (_val: any, row: any) => <span className="text-[13px]">{BOM_TYPE_OPTIONS.find((o) => o.code === row.bom_type)?.label || row.bom_type || "-"}</span>
|
||||
: 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -1040,8 +1040,25 @@ export default function ProductionPlanManagementPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orderItems.map((item) => (
|
||||
<React.Fragment key={item.item_code}>
|
||||
{ts.groupData(orderItems).map((item, rowIdx) => {
|
||||
if (item._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
{ts.visibleColumns.map((col) => {
|
||||
const v = (item as any)[col.key];
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className={typeof v === "number" ? "text-right font-mono text-[13px]" : "text-[13px] text-primary"}>
|
||||
{typeof v === "number" ? Number(v).toLocaleString() : (v || "")}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={item.item_code || rowIdx}>
|
||||
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
|
||||
@@ -1093,7 +1110,8 @@ export default function ProductionPlanManagementPage() {
|
||||
</TableRow>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{!selectedVersionId ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">버전을 선택해주세요</p>
|
||||
) : detailsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
<p className="mt-2 text-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : detailsGridData.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">등록된 공정이 없어요</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-10 text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={selectedDetailIds.size === detailsGridData.length && detailsGridData.length > 0}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSelectedDetailIds(new Set(detailsGridData.map((r) => r.id)));
|
||||
else setSelectedDetailIds(new Set());
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-center text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="text-muted-foreground">공정명</TableHead>
|
||||
<TableHead className="w-[80px] text-center text-muted-foreground">필수</TableHead>
|
||||
<TableHead className="w-[90px] text-center text-muted-foreground">순서고정</TableHead>
|
||||
<TableHead className="w-[100px] text-muted-foreground">작업구분</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-muted-foreground">표준시간</TableHead>
|
||||
<TableHead className="text-muted-foreground">외주업체</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailsGridData.map((row) => (
|
||||
<TableRow key={row.id} className="hover:bg-muted/30">
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedDetailIds.has(row.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedDetailIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(row.id);
|
||||
else next.delete(row.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{row.seq_no}</TableCell>
|
||||
<TableCell>{row.process_display}</TableCell>
|
||||
<TableCell className="text-center">{row.is_required}</TableCell>
|
||||
<TableCell className="text-center">{row.is_fixed_order}</TableCell>
|
||||
<TableCell>{row.work_type}</TableCell>
|
||||
<TableCell className="text-right">{row.standard_time}</TableCell>
|
||||
<TableCell>{row.outsource_display}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "seq_no", label: "순번", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "process_display", label: "공정명" },
|
||||
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||
{ key: "outsource_display", label: "외주업체" },
|
||||
] as EDataTableColumn[]}
|
||||
data={detailsGridData}
|
||||
rowKey={(row) => row.id}
|
||||
loading={detailsLoading}
|
||||
emptyMessage="등록된 공정이 없어요"
|
||||
showCheckbox
|
||||
checkedIds={Array.from(selectedDetailIds)}
|
||||
onCheckedChange={(ids) => setSelectedDetailIds(new Set(ids))}
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
{/* 공정 목록 테이블 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{listBusy ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
<p className="mt-2 text-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : processGridData.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">조회된 공정이 없어요</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-10 text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={selectedIds.size === processGridData.length && processGridData.length > 0}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSelectedIds(new Set(processGridData.map((r) => r.id)));
|
||||
else setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px] text-muted-foreground">공정코드</TableHead>
|
||||
<TableHead className="text-muted-foreground">공정명</TableHead>
|
||||
<TableHead className="w-[120px] text-muted-foreground">공정유형</TableHead>
|
||||
<TableHead className="w-[110px] text-right text-muted-foreground">표준시간(분)</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-muted-foreground">작업인원</TableHead>
|
||||
<TableHead className="w-[90px] text-center text-muted-foreground">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processGridData.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={selectedProcess?.id === row.id ? "selected" : undefined}
|
||||
className="cursor-pointer hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
const proc = processes.find((p) => p.id === row.id);
|
||||
setSelectedProcess(proc || null);
|
||||
}}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(row.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(row.id);
|
||||
else next.delete(row.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.process_code}</TableCell>
|
||||
<TableCell>{row.process_name}</TableCell>
|
||||
<TableCell>{row.process_type_display}</TableCell>
|
||||
<TableCell className="text-right">{row.standard_time}</TableCell>
|
||||
<TableCell className="text-right">{row.worker_count}</TableCell>
|
||||
<TableCell className="text-center">{row.use_yn_display}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "process_code", label: "공정코드", width: "w-[130px]", render: (val: any) => <span className="font-mono text-xs">{val}</span> },
|
||||
{ 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{ts.isVisible("work_instruction_no") && <TableHead style={ts.thStyle("work_instruction_no")} className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업지시번호</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
{ts.isVisible("progress") && <TableHead style={ts.thStyle("progress")} className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">진행현황</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{ts.isVisible("qty") && <TableHead style={ts.thStyle("qty")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>}
|
||||
{ts.isVisible("equipment") && <TableHead style={ts.thStyle("equipment")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>}
|
||||
{ts.isVisible("routing") && <TableHead style={ts.thStyle("routing")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>}
|
||||
{ts.isVisible("work_team") && <TableHead style={ts.thStyle("work_team")} className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>}
|
||||
{ts.isVisible("worker") && <TableHead style={ts.thStyle("worker")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>}
|
||||
{ts.isVisible("start_date") && <TableHead style={ts.thStyle("start_date")} className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>}
|
||||
{ts.isVisible("end_date") && <TableHead style={ts.thStyle("end_date")} className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료일</TableHead>}
|
||||
{ts.isVisible("actions") && <TableHead style={ts.thStyle("actions")} className="w-[150px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={13} className="text-center py-16"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="py-16">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
|
||||
<Inbox className="w-6 h-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">등록된 작업지시가 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/60">새로운 작업지시를 등록해주세요</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : 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;
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "work_instruction_no", label: "작업지시번호", width: "w-[150px]", render: (_v, row) => <span className="font-mono text-[13px] font-medium">{getDisplayNo(row)}</span> },
|
||||
{ key: "status", label: "상태", width: "w-[70px]", align: "center", render: (v) => {
|
||||
const sBadge = STATUS_BADGE[v] || STATUS_BADGE["일반"];
|
||||
return <Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge>;
|
||||
}},
|
||||
{ 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 <span className="text-[10px] text-muted-foreground">↑</span>;
|
||||
const pct = getProgress(row);
|
||||
const pLabel = getProgressLabel(row);
|
||||
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-success" : pct > 0 ? "bg-primary" : "bg-muted-foreground/30")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}},
|
||||
{ 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 (
|
||||
<TableRow key={`${o.wi_id}-${o.detail_id}`} className="hover:bg-muted/30">
|
||||
{ts.isVisible("work_instruction_no") && <TableCell style={ts.thStyle("work_instruction_no")} className="font-mono text-[13px] font-medium">{getDisplayNo(o)}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>}
|
||||
{ts.isVisible("progress") && <TableCell style={ts.thStyle("progress")} className="text-center">
|
||||
{isFirstOfGroup ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-success" : pct > 0 ? "bg-primary" : "bg-muted-foreground/30")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
) : <span className="text-[10px] text-muted-foreground">↑</span>}
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="text-sm">{o.item_name || o.item_number || "-"}</TableCell>}
|
||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="text-[13px] text-muted-foreground">{o.item_spec || "-"}</TableCell>}
|
||||
{ts.isVisible("qty") && <TableCell style={ts.thStyle("qty")} className="text-right text-[13px] font-mono font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>}
|
||||
{ts.isVisible("equipment") && <TableCell style={ts.thStyle("equipment")} className="text-[13px]">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("routing") && <TableCell style={ts.thStyle("routing")} className="text-[13px]">
|
||||
{isFirstOfGroup ? (
|
||||
o.routing_version_id ? (
|
||||
<button
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
openWorkStandardModal(
|
||||
o.work_instruction_no,
|
||||
o.routing_version_id,
|
||||
o.routing_name || "",
|
||||
o.item_name || o.item_number || "",
|
||||
o.item_number || ""
|
||||
);
|
||||
}}
|
||||
>
|
||||
{o.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
|
||||
</button>
|
||||
) : <span className="text-muted-foreground">-</span>
|
||||
) : ""}
|
||||
</TableCell>}
|
||||
{ts.isVisible("work_team") && <TableCell style={ts.thStyle("work_team")} className="text-center text-[13px]">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("worker") && <TableCell style={ts.thStyle("worker")} className="text-[13px]">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>}
|
||||
{ts.isVisible("start_date") && <TableCell style={ts.thStyle("start_date")} className="text-center text-[13px] font-mono">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("end_date") && <TableCell style={ts.thStyle("end_date")} className="text-center text-[13px] font-mono">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("actions") && <TableCell style={ts.thStyle("actions")} className="text-center">
|
||||
{isFirstOfGroup && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3" /> 수정</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2 text-destructive hover:text-destructive" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3" /></Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
<button
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
openWorkStandardModal(row.work_instruction_no, row.routing_version_id, row.routing_name || "", row.item_name || row.item_number || "", row.item_number || "");
|
||||
}}
|
||||
>
|
||||
{row.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
}
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}},
|
||||
{ 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 (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(row)}><Pencil className="w-3 h-3" /> 수정</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2 text-destructive hover:text-destructive" onClick={() => handleDelete(row.wi_id)}><Trash2 className="w-3 h-3" /></Button>
|
||||
</div>
|
||||
);
|
||||
}},
|
||||
] as EDataTableColumn[]}
|
||||
data={ts.groupData(orders)}
|
||||
rowKey={(row) => `${row.wi_id}-${row.detail_id}`}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 작업지시가 없어요"
|
||||
showPagination
|
||||
draggableColumns
|
||||
columnOrderKey="c16-work-instruction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
@@ -638,90 +637,32 @@ export default function PurchaseOrderPage() {
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<div className="flex-1 overflow-auto border rounded-lg bg-card">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[44px] text-center">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
data-state={someChecked ? "indeterminate" : undefined}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIds(checked ? orders.map((o) => o.id) : []);
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.isVisible("purchase_no") && <TableHead style={ts.thStyle("purchase_no")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주번호</TableHead>}
|
||||
{ts.isVisible("order_date") && <TableHead style={ts.thStyle("order_date")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주일</TableHead>}
|
||||
{ts.isVisible("supplier_name") && <TableHead style={ts.thStyle("supplier_name")} className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체</TableHead>}
|
||||
{ts.isVisible("item_code") && <TableHead style={ts.thStyle("item_code")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{ts.isVisible("order_qty") && <TableHead style={ts.thStyle("order_qty")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주수량</TableHead>}
|
||||
{ts.isVisible("received_qty") && <TableHead style={ts.thStyle("received_qty")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>}
|
||||
{ts.isVisible("remain_qty") && <TableHead style={ts.thStyle("remain_qty")} className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>}
|
||||
{ts.isVisible("unit_price") && <TableHead style={ts.thStyle("unit_price")} className="w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>}
|
||||
{ts.isVisible("amount") && <TableHead style={ts.thStyle("amount")} className="w-[110px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>}
|
||||
{ts.isVisible("due_date") && <TableHead style={ts.thStyle("due_date")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
{ts.isVisible("memo") && <TableHead style={ts.thStyle("memo")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-12 text-muted-foreground text-sm">
|
||||
등록된 발주가 없어요
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : orders.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", checkedIds.includes(row.id) && "bg-primary/5")}
|
||||
onDoubleClick={() => openEditModal(row.purchase_no)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(row.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIds((prev) =>
|
||||
checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
{ts.isVisible("purchase_no") && <TableCell style={ts.thStyle("purchase_no")} className="text-[13px] font-mono">{row.purchase_no}</TableCell>}
|
||||
{ts.isVisible("order_date") && <TableCell style={ts.thStyle("order_date")} className="text-[13px]">{row.order_date}</TableCell>}
|
||||
{ts.isVisible("supplier_name") && <TableCell style={ts.thStyle("supplier_name")} className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.supplier_name}>{row.supplier_name}</span></TableCell>}
|
||||
{ts.isVisible("item_code") && <TableCell style={ts.thStyle("item_code")} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>}
|
||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>}
|
||||
{ts.isVisible("order_qty") && <TableCell style={ts.thStyle("order_qty")} className="text-[13px] text-right font-mono">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("received_qty") && <TableCell style={ts.thStyle("received_qty")} className="text-[13px] text-right font-mono">{row.received_qty ? Number(row.received_qty).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("remain_qty") && <TableCell style={ts.thStyle("remain_qty")} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("unit_price") && <TableCell style={ts.thStyle("unit_price")} className="text-[13px] text-right font-mono">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("amount") && <TableCell style={ts.thStyle("amount")} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("due_date") && <TableCell style={ts.thStyle("due_date")} className="text-[13px]">{row.due_date}</TableCell>}
|
||||
{ts.isVisible("status") && (
|
||||
<TableCell style={ts.thStyle("status")}>
|
||||
{row.status && (
|
||||
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[row.status] || "")}>
|
||||
{row.status}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("memo") && <TableCell style={ts.thStyle("memo")} className="text-[13px] text-muted-foreground max-w-[120px]"><span className="block truncate">{row.memo}</span></TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
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 ? (
|
||||
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[row.status] || "")}>
|
||||
{row.status}
|
||||
</span>
|
||||
) : 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 발주 등록/수정 모달 */}
|
||||
|
||||
@@ -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<EDataTableColumn[]>(() => {
|
||||
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) => (
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||
v === "ACTIVE" || v === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}>{v || "-"}</span>
|
||||
),
|
||||
});
|
||||
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() {
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
{isColVisible("size") && <TableHead style={ts.thStyle("size")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{isColVisible("unit") && <TableHead style={ts.thStyle("unit")} className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>}
|
||||
{isColVisible("standard_price") && <TableHead style={ts.thStyle("standard_price")} className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준단가</TableHead>}
|
||||
{isColVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[60px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemLoading ? (
|
||||
<TableRow><TableCell colSpan={itemColSpan} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||
) : items.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={itemColSpan} className="h-32 text-center text-muted-foreground text-sm">등록된 구매품목이 없어요</TableCell></TableRow>
|
||||
) : items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs border-l-2",
|
||||
selectedItemId === item.id ? "border-l-primary bg-primary/5" : "border-l-transparent"
|
||||
)}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
onDoubleClick={openEditItem}
|
||||
>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[110px]">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[160px]">{item.item_name}</TableCell>
|
||||
{isColVisible("size") && <TableCell style={ts.thStyle("size")} className="p-2 truncate">{item.size || "-"}</TableCell>}
|
||||
{isColVisible("unit") && <TableCell style={ts.thStyle("unit")} className="p-2">{item.unit || "-"}</TableCell>}
|
||||
{isColVisible("standard_price") && <TableCell style={ts.thStyle("standard_price")} className="p-2 text-right">{item.standard_price ? Number(item.standard_price).toLocaleString() : "-"}</TableCell>}
|
||||
{isColVisible("status") && (
|
||||
<TableCell style={ts.thStyle("status")} className="p-2 text-center">
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||
item.status === "ACTIVE" || item.status === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}>{item.status || "-"}</span>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={mainTableColumns}
|
||||
data={ts.groupData(items)}
|
||||
loading={itemLoading}
|
||||
emptyMessage="등록된 구매품목이 없어요"
|
||||
selectedId={selectedItemId}
|
||||
onSelect={(id) => setSelectedItemId(id)}
|
||||
onRowDoubleClick={() => openEditItem()}
|
||||
showPagination={true}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-purchase-item-main"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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<EDataTableColumn[]>(() => {
|
||||
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) => (
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||
v === "ACTIVE" || v === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}>{v || "-"}</span>
|
||||
),
|
||||
});
|
||||
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() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체코드</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체명</TableHead>
|
||||
{isColVisible("contact_person") && <TableHead style={ts.thStyle("contact_person")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>}
|
||||
{isColVisible("contact_phone") && <TableHead style={ts.thStyle("contact_phone")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">연락처</TableHead>}
|
||||
{isColVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{supplierLoading ? (
|
||||
<TableRow><TableCell colSpan={supplierColSpan} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||
) : suppliers.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={supplierColSpan} className="h-32 text-center text-muted-foreground text-sm">등록된 공급업체가 없어요</TableCell></TableRow>
|
||||
) : suppliers.map((s) => (
|
||||
<TableRow
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs border-l-2",
|
||||
selectedSupplierId === s.id ? "border-l-primary bg-primary/5" : "border-l-transparent"
|
||||
)}
|
||||
onClick={() => setSelectedSupplierId(s.id)}
|
||||
onDoubleClick={openSupplierEdit}
|
||||
>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[120px]">{s.supplier_code}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[160px]">{s.supplier_name}</TableCell>
|
||||
{isColVisible("contact_person") && <TableCell style={ts.thStyle("contact_person")} className="p-2 truncate">{s.contact_person || "-"}</TableCell>}
|
||||
{isColVisible("contact_phone") && <TableCell style={ts.thStyle("contact_phone")} className="p-2 truncate">{s.contact_phone || "-"}</TableCell>}
|
||||
{isColVisible("status") && (
|
||||
<TableCell style={ts.thStyle("status")} className="p-2 text-center">
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||
s.status === "ACTIVE" || s.status === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}>{s.status || "-"}</span>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={mainTableColumns}
|
||||
data={ts.groupData(suppliers)}
|
||||
loading={supplierLoading}
|
||||
emptyMessage="등록된 공급업체가 없어요"
|
||||
selectedId={selectedSupplierId}
|
||||
onSelect={(id) => setSelectedSupplierId(id)}
|
||||
onRowDoubleClick={() => openSupplierEdit()}
|
||||
showPagination={true}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-supplier-main"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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<EDataTableColumn[]>(() => {
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={inspections.length > 0 && inspChecked.length === inspections.length}
|
||||
onCheckedChange={(v) => setInspChecked(v ? inspections.map((r) => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={ts.thStyle(col.key)}
|
||||
className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase"
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inspLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="py-8 text-center">
|
||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : inspections.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={ts.visibleColumns.length + 1}
|
||||
className="text-muted-foreground py-10 text-center"
|
||||
>
|
||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">등록된 검사기준이 없어요</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
inspections.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", inspChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() =>
|
||||
setInspChecked((prev) =>
|
||||
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id],
|
||||
)
|
||||
}
|
||||
onDoubleClick={() => openInspEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={inspChecked.includes(row.id)}
|
||||
onCheckedChange={(v) =>
|
||||
setInspChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "inspection_type")
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)}
|
||||
</TableCell>
|
||||
);
|
||||
if (col.key === "inspection_method")
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{getCatLabel(INSPECTION_TABLE, "inspection_method", row.inspection_method)}
|
||||
</TableCell>
|
||||
);
|
||||
if (col.key === "judgment_criteria")
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{getCatLabel(INSPECTION_TABLE, "judgment_criteria", row.judgment_criteria)}
|
||||
</TableCell>
|
||||
);
|
||||
if (col.key === "unit")
|
||||
return (
|
||||
<TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "unit", row.unit)}</TableCell>
|
||||
);
|
||||
if (col.key === "apply_type")
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
{getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)}
|
||||
</TableCell>
|
||||
);
|
||||
return <TableCell key={col.key}>{row[col.key] ?? ""}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={inspTableColumns}
|
||||
data={ts.groupData(inspections)}
|
||||
loading={inspLoading}
|
||||
emptyMessage="등록된 검사기준이 없어요"
|
||||
showCheckbox={true}
|
||||
checkedIds={inspChecked}
|
||||
onCheckedChange={setInspChecked}
|
||||
onRowDoubleClick={(row) => openInspEdit(row)}
|
||||
showPagination={true}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-inspection-main"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && checkedIds.length === data.length}
|
||||
onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 품목검사정보가 없어요</p></TableCell></TableRow>
|
||||
) : data.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", checkedIds.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={(v) => setCheckedIds(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
||||
{col.key === "is_active"
|
||||
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
||||
: row[col.key] ?? ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
render: col.key === "is_active"
|
||||
? (val: any, row: any) => (
|
||||
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">
|
||||
{row.is_active ? "사용" : "미사용"}
|
||||
</Badge>
|
||||
)
|
||||
: undefined,
|
||||
}))}
|
||||
data={ts.groupData(data)}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 품목검사정보가 없어요"
|
||||
showCheckbox
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
onRowDoubleClick={(row) => openEdit(row)}
|
||||
showPagination
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-item-inspection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide">#</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide">
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="h-32 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 클레임이 없어요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((claim, idx) => (
|
||||
<TableRow
|
||||
key={claim.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedClaimNo === claim.claim_no
|
||||
? "bg-primary/8 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => handleRowClick(claim.claim_no)}
|
||||
onDoubleClick={() => openEditModal(claim.claim_no)}
|
||||
>
|
||||
<TableCell className="text-center text-[11px] text-muted-foreground py-2">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "claim_type") {
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-center py-2">
|
||||
<Badge variant={getClaimTypeVariant(claim.claim_type)} className="text-[10px] px-1.5 py-0">
|
||||
{claim.claim_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "claim_status") {
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-center py-2">
|
||||
<Badge variant={getClaimStatusVariant(claim.claim_status)} className="text-[10px] px-1.5 py-0">
|
||||
{claim.claim_status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "claim_content") {
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-sm text-muted-foreground py-2 max-w-[200px] truncate">
|
||||
{claim.claim_content}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-sm py-2">
|
||||
{claim[col.key] ?? "-"}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn<ClaimRow> => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.key === "claim_type" || col.key === "claim_status" ? "center" : undefined,
|
||||
render: col.key === "claim_type"
|
||||
? (val: any) => (
|
||||
<Badge variant={getClaimTypeVariant(val)} className="text-[10px] px-1.5 py-0">
|
||||
{val}
|
||||
</Badge>
|
||||
)
|
||||
: col.key === "claim_status"
|
||||
? (val: any) => (
|
||||
<Badge variant={getClaimStatusVariant(val)} className="text-[10px] px-1.5 py-0">
|
||||
{val}
|
||||
</Badge>
|
||||
)
|
||||
: 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}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Truck, Package,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
|
||||
Settings2,
|
||||
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight,
|
||||
Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
@@ -40,8 +42,19 @@ const formatNumber = (val: string) => {
|
||||
};
|
||||
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<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() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
@@ -72,6 +179,9 @@ export default function SalesOrderPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
|
||||
const [detailRows, setDetailRows] = useState<any[]>([]);
|
||||
const [allowPriceEdit, setAllowPriceEdit] = useState(true);
|
||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
||||
const [closingOrders, setClosingOrders] = useState<Set<string>>(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<Record<string, Set<string>>>({});
|
||||
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<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) => {
|
||||
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 (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 브레드크럼 */}
|
||||
@@ -632,45 +884,123 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
{/* 데이터 테이블 (트리 구조) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="overflow-auto" style={{ maxHeight: "calc(100vh - 290px)" }}>
|
||||
<Table noWrapper className="w-full" style={{ tableLayout: "fixed" }}>
|
||||
<div className="h-full overflow-auto">
|
||||
<Table style={{ minWidth: "1500px" }}>
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} /> {/* 체크박스 */}
|
||||
<col style={{ width: "36px" }} /> {/* 펼침 화살표 */}
|
||||
<col style={{ width: "150px" }} /> {/* 수주번호 */}
|
||||
<col style={{ width: "120px" }} /> {/* 품번 / 거래처 */}
|
||||
<col style={{ width: "140px" }} /> {/* 품명 / 거래처(cont) */}
|
||||
<col style={{ width: "80px" }} /> {/* 규격 / 단가방식 */}
|
||||
<col style={{ width: "70px" }} /> {/* 단위 / 납품처 */}
|
||||
<col style={{ width: "80px" }} /> {/* 수량 / 납품처(cont) */}
|
||||
<col style={{ width: "80px" }} /> {/* 출하수량 / 납품장소 */}
|
||||
<col style={{ width: "80px" }} /> {/* 잔량 / 납품장소(cont) */}
|
||||
<col style={{ width: "90px" }} /> {/* 단가 / 수주일 */}
|
||||
<col style={{ width: "110px" }} /> {/* 금액 / 수주일(cont) */}
|
||||
<col style={{ width: "60px" }} /> {/* 통화 / 담당자 */}
|
||||
<col style={{ width: "100px" }} /> {/* 납기일 / 담당자(cont) */}
|
||||
<col style={{ width: "120px" }} /> {/* 메모 */}
|
||||
</colgroup>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10 pl-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllChecked}
|
||||
onChange={toggleAllChecked}
|
||||
className="h-4 w-4 cursor-pointer rounded border-border accent-primary"
|
||||
<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
|
||||
checked={(() => {
|
||||
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
|
||||
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
|
||||
})()}
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.isVisible("order_no") && <TableHead style={ts.thStyle("order_no")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead>}
|
||||
{ts.isVisible("part_code") && <TableHead style={ts.thStyle("part_code")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
||||
{ts.isVisible("part_name") && <TableHead style={ts.thStyle("part_name")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{ts.isVisible("unit") && <TableHead style={ts.thStyle("unit")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>}
|
||||
{ts.isVisible("qty") && <TableHead style={ts.thStyle("qty")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>}
|
||||
{ts.isVisible("ship_qty") && <TableHead style={ts.thStyle("ship_qty")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하수량</TableHead>}
|
||||
{ts.isVisible("balance_qty") && <TableHead style={ts.thStyle("balance_qty")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>}
|
||||
{ts.isVisible("unit_price") && <TableHead style={ts.thStyle("unit_price")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>}
|
||||
{ts.isVisible("amount") && <TableHead style={ts.thStyle("amount")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>}
|
||||
{ts.isVisible("currency_code") && <TableHead style={ts.thStyle("currency_code")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>}
|
||||
{ts.isVisible("due_date") && <TableHead style={ts.thStyle("due_date")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>}
|
||||
{ts.isVisible("memo") && <TableHead style={ts.thStyle("memo")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>}
|
||||
<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("order_no")}>
|
||||
<span className="truncate">수주번호</span>
|
||||
{sortState?.key === "order_no" && (
|
||||
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["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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} 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" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : orders.length === 0 ? (
|
||||
) : Object.keys(filteredOrderGroups).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} 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">
|
||||
<ClipboardList className="h-8 w-8 opacity-30" />
|
||||
<span className="text-sm">등록된 수주가 없어요</span>
|
||||
@@ -678,49 +1008,200 @@ export default function SalesOrderPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
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 (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setCheckedIds((prev) =>
|
||||
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
|
||||
)}
|
||||
onDoubleClick={() => openEditModal(row.order_no)}
|
||||
>
|
||||
<TableCell className="pl-4">
|
||||
<input type="checkbox" checked={isChecked} readOnly className="h-4 w-4 cursor-pointer rounded accent-primary" />
|
||||
</TableCell>
|
||||
{ts.isVisible("order_no") && <TableCell style={ts.thStyle("order_no")} className="font-mono text-[13px]">{row.order_no}</TableCell>}
|
||||
{ts.isVisible("part_code") && (
|
||||
<TableCell style={ts.thStyle("part_code")} className="max-w-[120px]">
|
||||
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
|
||||
<React.Fragment key={orderNo}>
|
||||
{/* 마스터 행 — 마스터 테이블 필드만 표시 */}
|
||||
<TableRow
|
||||
style={{ borderTop: "2px solid hsl(var(--border))" }}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent font-semibold tree-master-row",
|
||||
allDetailChecked && "border-l-primary bg-primary/5"
|
||||
)}
|
||||
onClick={() => {
|
||||
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)}
|
||||
>
|
||||
<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
|
||||
checked={allDetailChecked}
|
||||
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("part_name") && (
|
||||
<TableCell style={ts.thStyle("part_name")} className="max-w-[150px]">
|
||||
<span className="block truncate text-sm" title={row.part_name}>{row.part_name}</span>
|
||||
<TableCell className="text-center">
|
||||
{isExpanded
|
||||
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>}
|
||||
{ts.isVisible("unit") && <TableCell style={ts.thStyle("unit")} className="text-[13px]">{row.unit}</TableCell>}
|
||||
{ts.isVisible("qty") && <TableCell style={ts.thStyle("qty")} className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("ship_qty") && <TableCell style={ts.thStyle("ship_qty")} className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("balance_qty") && <TableCell style={ts.thStyle("balance_qty")} className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("unit_price") && <TableCell style={ts.thStyle("unit_price")} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("amount") && <TableCell style={ts.thStyle("amount")} className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>}
|
||||
{ts.isVisible("currency_code") && <TableCell style={ts.thStyle("currency_code")} className="text-[13px]">{row.currency_code}</TableCell>}
|
||||
{ts.isVisible("due_date") && <TableCell style={ts.thStyle("due_date")} className="text-[13px]">{row.due_date}</TableCell>}
|
||||
{ts.isVisible("memo") && (
|
||||
<TableCell style={ts.thStyle("memo")} className="max-w-[100px]">
|
||||
<span className="block truncate text-[13px] text-muted-foreground" title={row.memo}>{row.memo}</span>
|
||||
{/* 수주번호 */}
|
||||
<TableCell className="font-mono whitespace-nowrap">
|
||||
{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) : ""}
|
||||
</span>
|
||||
</TableCell>
|
||||
{/* 단가방식 (colSpan=1) */}
|
||||
<TableCell className="text-[13px] truncate max-w-0">
|
||||
<span className="block truncate">
|
||||
{master.price_mode ? (categoryOptions["price_mode"]?.find((o) => o.code === master.price_mode)?.label || master.price_mode) : ""}
|
||||
</span>
|
||||
</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">
|
||||
<span className="block truncate max-w-[120px]">{master.memo || ""}</span>
|
||||
</TableCell>
|
||||
</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>
|
||||
)}
|
||||
</TableRow>
|
||||
|
||||
{/* 디테일 행 (펼쳤을 때만) */}
|
||||
{isExpanded && group.details.map((row, detailIdx) => {
|
||||
const isClosing = closingOrders.has(orderNo);
|
||||
const isChecked = checkedIds.includes(row.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||
isClosing ? "tree-detail-row-closing" : "tree-detail-row",
|
||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
setCheckedIds((prev) =>
|
||||
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
|
||||
);
|
||||
}}
|
||||
onDoubleClick={() => openEditModal(row.order_no)}
|
||||
>
|
||||
<TableCell
|
||||
className="text-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCheckedIds((prev) =>
|
||||
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
|
||||
</TableCell>
|
||||
<TableCell className="relative">
|
||||
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
|
||||
</TableCell>
|
||||
<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] text-muted-foreground">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.unit}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_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] 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 />
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -731,7 +1212,7 @@ export default function SalesOrderPage() {
|
||||
|
||||
{/* 수주 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex flex-col gap-0 p-0 max-w-[95vw] w-full" style={{ maxHeight: "92vh" }}>
|
||||
<DialogContent className="flex flex-col gap-0 p-0 max-w-5xl w-full" style={{ maxHeight: "85vh" }}>
|
||||
<DialogHeader className="shrink-0 border-b border-border px-6 py-5">
|
||||
<DialogTitle className="text-[17px] font-bold">
|
||||
{isEditMode ? "수주 수정" : "수주 등록"}
|
||||
@@ -805,6 +1286,12 @@ export default function SalesOrderPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox checked={allowPriceEdit} onCheckedChange={(v) => setAllowPriceEdit(!!v)} />
|
||||
<span className="text-sm font-medium">단가 수정 허용</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -913,7 +1400,16 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">통화</Label>
|
||||
<Input value={masterForm.currency || ""} onChange={(e) => setMasterForm((p) => ({ ...p, currency: e.target.value }))} placeholder="KRW" className="h-9" />
|
||||
<Select value={masterForm.currency || "KRW"} onValueChange={(v) => setMasterForm((p) => ({ ...p, currency: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="통화 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="KRW">KRW (원)</SelectItem>
|
||||
<SelectItem value="USD">USD (달러)</SelectItem>
|
||||
<SelectItem value="EUR">EUR (유로)</SelectItem>
|
||||
<SelectItem value="JPY">JPY (엔)</SelectItem>
|
||||
<SelectItem value="CNY">CNY (위안)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">선적항</Label>
|
||||
@@ -958,21 +1454,22 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<Table noWrapper className="w-full">
|
||||
<Table noWrapper className="min-w-max w-full">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10 text-center text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
<TableHead className="w-28 text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="w-32 text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-20 text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-16 text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-24 text-right text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준단가</TableHead>
|
||||
<TableHead className="w-24 text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-24 text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-24 text-right text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
<TableHead className="w-16 text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>
|
||||
<TableHead className="w-36 text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-28 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">재질</TableHead>
|
||||
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">포장재</TableHead>
|
||||
<TableHead className="w-20 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-20 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">포장수량</TableHead>
|
||||
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-24 text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>
|
||||
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>
|
||||
<TableHead className="w-24 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">분할/삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -986,33 +1483,53 @@ export default function SalesOrderPage() {
|
||||
<span className="block truncate text-[13px]" title={row.part_name}>{row.part_name}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.unit}</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] text-muted-foreground">
|
||||
{row.standard_price ? Number(row.standard_price).toLocaleString() : ""}
|
||||
<TableCell className="text-[13px] text-muted-foreground">{row.material}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.packing_material || ""}
|
||||
onChange={(e) => updateDetailRow(idx, "packing_material", e.target.value)}
|
||||
placeholder="포장재"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={row.unit || ""} onValueChange={(v) => updateDetailRow(idx, "unit", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="단위" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["item_unit"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={formatNumber(row.qty || "")}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={row.pack_qty || "0"}
|
||||
onChange={(e) => updateDetailRow(idx, "pack_qty", e.target.value)}
|
||||
className="h-8 text-xs text-right font-mono w-16"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={formatNumber(row.unit_price || "")}
|
||||
onChange={(e) => 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")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-[13px] font-semibold">
|
||||
{row.amount ? Number(row.amount).toLocaleString() : ""}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.currency_code || ""}
|
||||
onChange={(e) => updateDetailRow(idx, "currency_code", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
{row.amount ? Number(row.amount).toLocaleString() : "0"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
@@ -1022,14 +1539,31 @@ export default function SalesOrderPage() {
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5"
|
||||
onClick={() => removeDetailRow(idx)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center gap-1 justify-center">
|
||||
<Button
|
||||
variant="default" size="sm"
|
||||
className="h-7 px-2 text-[11px] bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
onClick={() => {
|
||||
setDetailRows((prev) => {
|
||||
const next = [...prev];
|
||||
const newRow = { ...next[idx], _id: `split_${Date.now()}_${Math.random()}` };
|
||||
next.splice(idx + 1, 0, newRow);
|
||||
return next;
|
||||
});
|
||||
toast.success("행이 분할되었어요");
|
||||
}}
|
||||
>
|
||||
분할
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground/50 hover:text-destructive hover:bg-destructive/5"
|
||||
onClick={() => removeDetailRow(idx)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
|
||||
@@ -649,58 +662,20 @@ export default function SalesItemPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{itemLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
등록된 판매품목이 없어요
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[32px] text-center">#</TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[110px]">품번</TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground min-w-[130px]">품명</TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[80px]">규격</TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[60px]">단위</TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[90px] text-right">기준단가</TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[90px] text-right">판매가격</TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[50px]">통화</TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[60px]">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-2 transition-all",
|
||||
selectedItemId === item.id
|
||||
? "bg-primary/[0.08] border-l-primary"
|
||||
: "border-l-transparent hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
onDoubleClick={() => openEditItem()}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-mono text-muted-foreground">{item.item_number}</TableCell>
|
||||
<TableCell className={cn("text-sm", selectedItemId === item.id ? "font-semibold text-foreground" : "text-foreground")}>{item.item_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{formatNum(item.standard_price)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{formatNum(item.selling_price)}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{item.currency_code}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{item.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={itemColumns}
|
||||
data={ts.groupData(items)}
|
||||
rowKey={(row) => row.id}
|
||||
loading={itemLoading}
|
||||
emptyMessage="등록된 판매품목이 없어요"
|
||||
selectedId={selectedItemId}
|
||||
onSelect={(id) => setSelectedItemId(id)}
|
||||
onRowDoubleClick={() => openEditItem()}
|
||||
showRowNumber
|
||||
showPagination={false}
|
||||
draggableColumns={false}
|
||||
columnOrderKey="c16-sales-item"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -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<number>();
|
||||
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<DataSourceType, string> = {
|
||||
shipmentPlan: "출하계획 목록",
|
||||
salesOrder: "수주정보 목록",
|
||||
@@ -454,138 +515,42 @@ export default function ShippingOrderPage() {
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-card flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={orders.length > 0 && checkedIds.length === orders.length}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.isVisible("instruction_no") && <TableHead style={ts.thStyle("instruction_no")} className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하지시번호</TableHead>}
|
||||
{ts.isVisible("ship_date") && <TableHead style={ts.thStyle("ship_date")} className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하일자</TableHead>}
|
||||
{ts.isVisible("customer_name") && <TableHead style={ts.thStyle("customer_name")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처명</TableHead>}
|
||||
{ts.isVisible("transport_company") && <TableHead style={ts.thStyle("transport_company")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">운송업체</TableHead>}
|
||||
{ts.isVisible("vehicle_no") && <TableHead style={ts.thStyle("vehicle_no")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">차량번호</TableHead>}
|
||||
{ts.isVisible("driver_name") && <TableHead style={ts.thStyle("driver_name")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기사명</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
{ts.isVisible("item_code") && <TableHead style={ts.thStyle("item_code")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
||||
{ts.isVisible("qty") && <TableHead style={ts.thStyle("qty")} className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>}
|
||||
{ts.isVisible("source_type") && <TableHead style={ts.thStyle("source_type")} className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스</TableHead>}
|
||||
{ts.isVisible("remark") && <TableHead style={ts.thStyle("remark")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="h-40 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
||||
<Inbox className="w-5 h-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">등록된 출하지시가 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/60">출하지시 등록 버튼으로 등록해주세요</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
orders.map((order: any) => {
|
||||
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className={cn("cursor-pointer transition-colors", selectedOrderId === order.id && "bg-primary/5")}
|
||||
onClick={() => setSelectedOrderId(order.id)}
|
||||
onDoubleClick={() => openModal(order)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(order.id)}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) setCheckedIds(p => [...p, order.id]);
|
||||
else setCheckedIds(p => p.filter(i => i !== order.id));
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
{ts.isVisible("instruction_no") && <TableCell style={ts.thStyle("instruction_no")} className="font-medium text-sm">{order.instruction_no}</TableCell>}
|
||||
{ts.isVisible("ship_date") && <TableCell style={ts.thStyle("ship_date")} className="text-center text-sm">{formatDate(order.instruction_date)}</TableCell>}
|
||||
{ts.isVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="text-sm">{order.customer_name || "-"}</TableCell>}
|
||||
{ts.isVisible("transport_company") && <TableCell style={ts.thStyle("transport_company")} className="text-sm">{order.carrier_name || "-"}</TableCell>}
|
||||
{ts.isVisible("vehicle_no") && <TableCell style={ts.thStyle("vehicle_no")} className="text-sm">{order.vehicle_no || "-"}</TableCell>}
|
||||
{ts.isVisible("driver_name") && <TableCell style={ts.thStyle("driver_name")} className="text-sm">{order.driver_name || "-"}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-center">
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(order.status))}>
|
||||
{getStatusLabel(order.status)}
|
||||
</span>
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_code") && <TableCell style={ts.thStyle("item_code")} className="text-sm">-</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="text-sm">-</TableCell>}
|
||||
{ts.isVisible("qty") && <TableCell style={ts.thStyle("qty")} className="text-right text-sm">0</TableCell>}
|
||||
{ts.isVisible("source_type") && <TableCell style={ts.thStyle("source_type")} className="text-center text-sm">-</TableCell>}
|
||||
{ts.isVisible("remark") && <TableCell style={ts.thStyle("remark")} className="text-[13px] text-muted-foreground truncate max-w-[100px]">{order.memo || "-"}</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return items.map((item: any, itemIdx: number) => (
|
||||
<TableRow
|
||||
key={`${order.id}-${item.id}`}
|
||||
className={cn("cursor-pointer transition-colors", selectedOrderId === order.id && "bg-primary/5")}
|
||||
onClick={() => setSelectedOrderId(order.id)}
|
||||
onDoubleClick={() => openModal(order)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
{itemIdx === 0 && (
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(order.id)}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) setCheckedIds(p => [...p, order.id]);
|
||||
else setCheckedIds(p => p.filter(i => i !== order.id));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
{ts.isVisible("instruction_no") && <TableCell style={ts.thStyle("instruction_no")} className="font-medium text-sm">{itemIdx === 0 ? order.instruction_no : ""}</TableCell>}
|
||||
{ts.isVisible("ship_date") && <TableCell style={ts.thStyle("ship_date")} className="text-center text-sm">{itemIdx === 0 ? formatDate(order.instruction_date) : ""}</TableCell>}
|
||||
{ts.isVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="text-sm">{itemIdx === 0 ? (order.customer_name || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("transport_company") && <TableCell style={ts.thStyle("transport_company")} className="text-sm">{itemIdx === 0 ? (order.carrier_name || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("vehicle_no") && <TableCell style={ts.thStyle("vehicle_no")} className="text-sm">{itemIdx === 0 ? (order.vehicle_no || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("driver_name") && <TableCell style={ts.thStyle("driver_name")} className="text-sm">{itemIdx === 0 ? (order.driver_name || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-center">
|
||||
{itemIdx === 0 && (
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(order.status))}>
|
||||
{getStatusLabel(order.status)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_code") && <TableCell style={ts.thStyle("item_code")} className="text-[13px] text-muted-foreground">{item.item_code}</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="font-medium text-sm">{item.item_name}</TableCell>}
|
||||
{ts.isVisible("qty") && <TableCell style={ts.thStyle("qty")} className="text-right text-sm">{Number(item.order_qty || 0).toLocaleString()}</TableCell>}
|
||||
{ts.isVisible("source_type") && <TableCell style={ts.thStyle("source_type")} className="text-center">
|
||||
{(() => {
|
||||
const b = getSourceBadge(item.source_type || "");
|
||||
return <span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", b.cls)}>{b.label}</span>;
|
||||
})()}
|
||||
</TableCell>}
|
||||
{ts.isVisible("remark") && <TableCell style={ts.thStyle("remark")} className="text-[13px] text-muted-foreground truncate max-w-[100px]">
|
||||
{itemIdx === 0 ? (order.memo || "-") : ""}
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
));
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
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 ? (
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(val))}>
|
||||
{getStatusLabel(val)}
|
||||
</span>
|
||||
) : null
|
||||
: col.key === "source_type"
|
||||
? (val: any) => {
|
||||
if (!val || val === "-") return <span>-</span>;
|
||||
const b = getSourceBadge(val);
|
||||
return <span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", b.cls)}>{b.label}</span>;
|
||||
}
|
||||
: 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
|
||||
@@ -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<string, ShipmentPlanListItem[]>();
|
||||
const grouped = ts.groupData(data);
|
||||
const orderMap = new Map<string, any[]>();
|
||||
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() {
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.isVisible("order_no") && <TableHead style={ts.thStyle("order_no")} className="w-[10%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead>}
|
||||
{ts.isVisible("due_date") && <TableHead style={ts.thStyle("due_date")} className="w-[8%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>}
|
||||
{ts.isVisible("customer_name") && <TableHead style={ts.thStyle("customer_name")} className="w-[12%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처</TableHead>}
|
||||
{ts.isVisible("part_code") && <TableHead style={ts.thStyle("part_code")} className="w-[18%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>}
|
||||
{ts.isVisible("part_name") && <TableHead style={ts.thStyle("part_name")} className="w-[18%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
||||
{ts.isVisible("order_qty") && <TableHead style={ts.thStyle("order_qty")} className="w-[7%] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주수량</TableHead>}
|
||||
{ts.isVisible("plan_qty") && <TableHead style={ts.thStyle("plan_qty")} className="w-[7%] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획수량</TableHead>}
|
||||
{ts.isVisible("plan_date") && <TableHead style={ts.thStyle("plan_date")} className="w-[8%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획일</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[6%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-40 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
||||
<Inbox className="w-5 h-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">출하계획이 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/60">조건을 변경해서 다시 조회해주세요</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupedData.map(group =>
|
||||
group.plans.map((plan, planIdx) => (
|
||||
<TableRow
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedId === plan.id && "bg-primary/5",
|
||||
plan.status === "CANCELLED" && "opacity-50",
|
||||
planIdx === 0 && "border-t-2 border-t-border"
|
||||
)}
|
||||
onClick={() => handleRowClick(plan)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||
{planIdx === 0 && (
|
||||
<Checkbox
|
||||
checked={group.plans.every(p => 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)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
{ts.isVisible("order_no") && <TableCell style={ts.thStyle("order_no")} className="font-medium text-sm">
|
||||
{planIdx === 0 ? (plan.order_no || "-") : ""}
|
||||
</TableCell>}
|
||||
{ts.isVisible("due_date") && <TableCell style={ts.thStyle("due_date")} className="text-center text-sm">
|
||||
{planIdx === 0 ? formatDate(plan.due_date) : ""}
|
||||
</TableCell>}
|
||||
{ts.isVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="text-sm">
|
||||
{planIdx === 0 ? (plan.customer_name || "-") : ""}
|
||||
</TableCell>}
|
||||
{ts.isVisible("part_code") && <TableCell style={ts.thStyle("part_code")} className="text-muted-foreground text-[13px]">{plan.part_code || "-"}</TableCell>}
|
||||
{ts.isVisible("part_name") && <TableCell style={ts.thStyle("part_name")} className="font-medium text-sm">{plan.part_name || "-"}</TableCell>}
|
||||
{ts.isVisible("order_qty") && <TableCell style={ts.thStyle("order_qty")} className="text-right text-sm">{formatNumber(plan.order_qty)}</TableCell>}
|
||||
{ts.isVisible("plan_qty") && <TableCell style={ts.thStyle("plan_qty")} className="text-right font-semibold text-primary text-sm">{formatNumber(plan.plan_qty)}</TableCell>}
|
||||
{ts.isVisible("plan_date") && <TableCell style={ts.thStyle("plan_date")} className="text-center text-sm">{formatDate(plan.plan_date)}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-center">
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(plan.status))}>
|
||||
{getStatusLabel(plan.status)}
|
||||
</span>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "order_no", label: "수주번호", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
||||
{ key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
||||
{ key: "customer_name", label: "거래처", render: (val: any) => <span className="text-sm">{val || "-"}</span> },
|
||||
{ key: "part_code", label: "품목코드", render: (val: any) => <span className="text-muted-foreground text-[13px]">{val || "-"}</span> },
|
||||
{ key: "part_name", label: "품목명", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
||||
{ key: "order_qty", label: "수주수량", align: "right" as const, formatNumber: true },
|
||||
{ key: "plan_qty", label: "계획수량", align: "right" as const, render: (val: any) => <span className="font-semibold text-primary text-sm">{formatNumber(val)}</span> },
|
||||
{ key: "plan_date", label: "계획일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
||||
{ key: "status", label: "상태", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(val))}>{getStatusLabel(val)}</span> },
|
||||
] as EDataTableColumn<ShipmentPlanListItem>[]}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
821
frontend/components/common/EDataTable.tsx
Normal file
821
frontend/components/common/EDataTable.tsx
Normal file
@@ -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<T = any> {
|
||||
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<T extends Record<string, any> = any> {
|
||||
columns: EDataTableColumn<T>[];
|
||||
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<T>[]) => 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<string>;
|
||||
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 (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
col.width, col.minWidth,
|
||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative overflow-hidden",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center",
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
col.align === "right" && "justify-end w-full",
|
||||
col.align === "center" && "justify-center w-full",
|
||||
)}>
|
||||
{/* 드래그 핸들 */}
|
||||
{draggable && (
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab text-muted-foreground/40 hover:text-muted-foreground shrink-0"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 라벨 + 정렬 */}
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (col.sortable !== false) onSort(col.key);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{col.label}</span>
|
||||
{isSorted && (
|
||||
sortDir === "asc"
|
||||
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
|
||||
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 아이콘 + Popover */}
|
||||
{col.filterable !== false && uniqueValues.length > 0 && (
|
||||
<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">필터: {col.label}</span>
|
||||
{hasFilter && (
|
||||
<button onClick={() => onClearFilter(col.key)} 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">
|
||||
{filteredUniqueValues.slice(0, 100).map((val) => {
|
||||
const isSelected = headerFilterValues.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={() => onToggleFilter(col.key, 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>
|
||||
);
|
||||
})}
|
||||
{filteredUniqueValues.length > 100 && (
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||
...외 {filteredUniqueValues.length - 100}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── EDataTable ───
|
||||
|
||||
export function EDataTable<T extends Record<string, any> = 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<T>) {
|
||||
const [columns, setColumns] = useState(initialColumns);
|
||||
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
|
||||
|
||||
// 정렬
|
||||
const [internalSort, setInternalSort] = useState<SortState | null>(null);
|
||||
const sortState = externalSort !== undefined ? externalSort : internalSort;
|
||||
|
||||
// 헤더 필터
|
||||
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||
|
||||
// 페이지네이션
|
||||
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<HTMLInputElement | HTMLSelectElement>(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<T>[];
|
||||
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<string, string[]> = {};
|
||||
for (const col of columns) {
|
||||
if (col.filterable === false) continue;
|
||||
const values = new Set<string>();
|
||||
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<T>, 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 (
|
||||
<select
|
||||
ref={editRef as any}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={() => saveEdit()}
|
||||
className="h-8 w-full rounded border border-primary bg-background px-2 text-[13px] focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{col.selectOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
ref={editRef as any}
|
||||
type={col.inputType === "number" ? "number" : col.inputType === "date" ? "date" : "text"}
|
||||
value={editValue}
|
||||
onChange={(e) => 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 (
|
||||
<span
|
||||
className={cn(
|
||||
col.truncate !== false && "block truncate",
|
||||
col.align === "right" && "text-right w-full inline-block",
|
||||
col.align === "center" && "text-center w-full inline-block",
|
||||
)}
|
||||
title={String(val ?? "")}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full flex-1 min-h-0", className)}>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table className="min-w-max">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow className="bg-muted hover:bg-muted h-10">
|
||||
{/* 체크박스 */}
|
||||
{showCheckbox && (
|
||||
<TableHead className="w-10 text-center">
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
onCheckedChange?.(checked ? processedData.map((r) => getRowId(r, rowKey)) : []);
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{/* 행번호 */}
|
||||
{showRowNumber && (
|
||||
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
#
|
||||
</TableHead>
|
||||
)}
|
||||
{/* 데이터 컬럼 */}
|
||||
{columns.map((col) => (
|
||||
<SortableHeaderCell
|
||||
key={col.key}
|
||||
col={col}
|
||||
sortKey={sortState?.key ?? null}
|
||||
sortDir={sortState?.direction ?? "asc"}
|
||||
onSort={handleSort}
|
||||
headerFilterValues={headerFilters[col.key] || new Set()}
|
||||
uniqueValues={columnUniqueValues[col.key] || []}
|
||||
onToggleFilter={toggleHeaderFilter}
|
||||
onClearFilter={clearHeaderFilter}
|
||||
draggable={draggableColumns}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
</SortableContext>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className="py-16 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className="py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
{emptyIcon || <Inbox className="h-8 w-8 opacity-30" />}
|
||||
<span className="text-sm">{emptyMessage}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((row, rowIdx) => {
|
||||
// 그룹 소계 행 처리
|
||||
if ((row as any)._isGroupSummary) {
|
||||
return (
|
||||
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
{showCheckbox && <TableCell />}
|
||||
{showRowNumber && <TableCell />}
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
typeof row[col.key] === "number" ? "text-right font-mono text-[13px]" : "text-[13px] text-primary",
|
||||
col.width, col.minWidth,
|
||||
)}
|
||||
>
|
||||
{typeof row[col.key] === "number" ? Number(row[col.key]).toLocaleString() : (row[col.key] || "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const id = getRowId(row, rowKey);
|
||||
const isSelected = selectedId != null && String(selectedId) === String(id);
|
||||
const isChecked = checkedIds.includes(id);
|
||||
const highlighted = isSelected || isChecked;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={id || rowIdx}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all h-[41px]",
|
||||
highlighted
|
||||
? "border-l-primary bg-primary/20 dark:bg-primary/15 row-selected"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
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 && (
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = checked
|
||||
? [...checkedIds, id]
|
||||
: checkedIds.filter((cid) => cid !== id);
|
||||
onCheckedChange?.(next);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{showRowNumber && (
|
||||
<TableCell className="text-center text-[11px] text-muted-foreground font-mono">
|
||||
{pageOffset + rowIdx + 1}
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
col.width, col.minWidth,
|
||||
col.editable && "cursor-text",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center",
|
||||
)}
|
||||
onDoubleClick={(e) => {
|
||||
if (col.editable) {
|
||||
e.stopPropagation();
|
||||
startEdit(id, col.key, row[col.key]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderCell(row, col, pageOffset + rowIdx)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{showPagination && (
|
||||
<div className="flex items-center justify-between border-t px-4 py-2.5 text-xs text-muted-foreground shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>전체</span>
|
||||
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
|
||||
<span>건</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pageSizeInput}
|
||||
onChange={(e) => setPageSizeInput(e.target.value)}
|
||||
onBlur={applyPageSize}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||||
className="h-7 w-16 text-center text-xs"
|
||||
/>
|
||||
<span>건씩 보기</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<ChevronsLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{getPageNumbers().map((page, idx) =>
|
||||
page === "..." ? (
|
||||
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||||
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages}
|
||||
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<ChevronsRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -652,20 +652,22 @@ export function TableSettingsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹별 합산 토글 */}
|
||||
<div className="mt-3 flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">그룹별 합산</div>
|
||||
<div className="text-xs text-muted-foreground">같은 값끼리 그룹핑하여 합산</div>
|
||||
</div>
|
||||
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 탭 3: 그룹 설정 ===== */}
|
||||
<TabsContent value="groups" className="mt-0 pt-3 overflow-y-auto max-h-[calc(80vh-220px)]">
|
||||
<div className="px-2 pb-3 border-b mb-2">
|
||||
<span className="text-sm font-medium">사용 가능한 컬럼</span>
|
||||
{/* 헤더 + 합산 토글 */}
|
||||
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">그룹 컬럼</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tempGroups.filter((g) => g.enabled).length}개 선택
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">소계 합산</span>
|
||||
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -43,7 +43,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,8 @@ export function useTableSettings<T extends { key: string }>(
|
||||
() => initialVisibleKeys || defaultColumns.map((c) => c.key),
|
||||
);
|
||||
const [baseFilter, setBaseFilter] = useState<BaseFilter | undefined>();
|
||||
const [groupColumns, setGroupColumns] = useState<string[]>([]);
|
||||
const [groupSumEnabled, setGroupSumEnabled] = useState(false);
|
||||
|
||||
// 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성)
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"]>(
|
||||
@@ -96,6 +98,11 @@ export function useTableSettings<T extends { key: string }>(
|
||||
|
||||
// 기본 데이터 필터
|
||||
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<T extends { key: string }>(
|
||||
[columnWidths],
|
||||
);
|
||||
|
||||
/**
|
||||
* 데이터를 그룹핑하고 소계 행을 삽입한 배열을 반환합니다.
|
||||
* groupColumns가 비어있으면 원본 배열을 그대로 반환합니다.
|
||||
* 소계 행은 _isGroupSummary: true, _groupKey, _groupValue 속성을 가집니다.
|
||||
*/
|
||||
const groupData = useCallback(
|
||||
<R extends Record<string, any>>(rows: R[]): (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] => {
|
||||
if (groupColumns.length === 0) return rows;
|
||||
|
||||
const groupCol = groupColumns[0]; // 첫 번째 그룹 컬럼 기준
|
||||
const groups = new Map<string, R[]>();
|
||||
|
||||
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<T extends { key: string }>(
|
||||
filterConfig,
|
||||
/** 기본 데이터 필터 (예: division = '판매') */
|
||||
baseFilter,
|
||||
/** 데이터 그룹핑 + 소계 삽입 함수 */
|
||||
groupData,
|
||||
/** 그룹 컬럼 목록 */
|
||||
groupColumns,
|
||||
/** 그룹별 합산 활성 여부 */
|
||||
groupSumEnabled,
|
||||
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
|
||||
defaultVisibleKeys: initialVisibleKeys || defaultColumns.map((c) => c.key),
|
||||
};
|
||||
|
||||
1
myfile.txt
Normal file
1
myfile.txt
Normal file
@@ -0,0 +1 @@
|
||||
hello
|
||||
168
scripts/dev/start-all-parallel-linux.sh
Executable file
168
scripts/dev/start-all-parallel-linux.sh
Executable file
@@ -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