Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-06 09:25:25 +09:00
46 changed files with 4673 additions and 3180 deletions

View File

@@ -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; }

View 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

View File

@@ -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>

View File

@@ -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>
{/* 상세 정보 다이얼로그 */}

View File

@@ -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" && (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
{/* 출고 등록 모달 */}

View File

@@ -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>
{/* 매칭 품목 서브패널 */}

View File

@@ -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>
{/* 입고 등록 모달 */}

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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}
/>
) : (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
{/* 발주 등록/수정 모달 */}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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>
{/* 등록/수정 모달 */}

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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);

View 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>
);
}

View File

@@ -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">

View File

@@ -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}
/>
);

View File

@@ -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
View File

@@ -0,0 +1 @@
hello

View 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 "============================================"

1
test.txt Normal file
View File

@@ -0,0 +1 @@
hello