김주석 의원장님 살려주세요

This commit is contained in:
gbpark
2026-04-05 22:58:34 +09:00
parent 9f4464342a
commit 7179fa21ea
8 changed files with 2385 additions and 652 deletions
@@ -95,21 +95,19 @@ html, body { height: 100%; overflow: hidden; font-size: 13px; }
═══════════════════════════════════════════ */
.main-content {
display: flex; flex: 1; overflow: hidden;
background: hsl(var(--background));
}
/* Master Panel (Left) */
.panel-master {
display: flex; flex-direction: column;
min-width: 250px; overflow: hidden;
background: hsl(var(--muted));
border-right: none;
background: hsl(var(--background));
}
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px;
padding: 10px 16px; height: 44px; min-height: 44px;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted));
background: hsl(var(--card));
}
.panel-header-left { display: flex; align-items: center; gap: 10px; }
.panel-title { font-size: 13px; font-weight: 700; color: hsl(var(--foreground)); }
@@ -121,26 +119,19 @@ html, body { height: 100%; overflow: hidden; font-size: 13px; }
/* Resize Handle */
.resize-handle {
width: 6px; min-width: 6px; cursor: col-resize;
background: hsl(var(--border)); transition: background 0.15s;
position: relative; z-index: 10;
width: 5px; min-width: 5px; cursor: col-resize;
background: hsl(var(--border) / 0.6); transition: all 0.15s;
position: relative; z-index: 10; flex-shrink: 0;
}
.resize-handle:hover, .resize-handle.active {
background: hsl(var(--primary));
background: hsl(var(--primary) / 0.5);
}
.resize-handle::after {
content: ''; position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 2px; height: 30px; border-radius: 2px;
background: hsl(var(--muted-foreground) / 0.5); opacity: 0; transition: opacity 0.15s;
}
.resize-handle:hover::after, .resize-handle.active::after { opacity: 1; }
/* Detail Panel (Right) */
.panel-detail {
display: flex; flex-direction: column;
min-width: 250px; flex: 1; overflow: hidden;
background: hsl(var(--muted));
background: hsl(var(--background));
}
/* ═══════════════════════════════════════════
@@ -148,6 +139,7 @@ html, body { height: 100%; overflow: hidden; font-size: 13px; }
═══════════════════════════════════════════ */
.table-wrapper {
flex: 1; overflow: auto; position: relative;
background: hsl(var(--background));
}
table {
width: 100%; border-collapse: collapse; table-layout: fixed;
@@ -156,22 +148,22 @@ thead { position: sticky; top: 0; z-index: 5; }
thead th {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: hsl(var(--muted-foreground));
padding: 10px 12px; text-align: left;
background: hsl(var(--card)); border-bottom: 1px solid hsl(var(--border));
padding: 9px 12px; text-align: left;
background: hsl(var(--muted)); border-bottom: 1px solid hsl(var(--border));
white-space: nowrap; user-select: none;
}
tbody tr {
border-bottom: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--border) / 0.5);
cursor: pointer; transition: all 0.1s;
border-left: 3px solid transparent;
}
tbody tr:hover { background: hsl(var(--accent)); }
tbody tr:hover { background: hsl(var(--accent) / 0.5); }
tbody tr.selected {
background: hsl(var(--primary) / 0.08);
background: hsl(var(--primary) / 0.06);
border-left: 3px solid hsl(var(--primary));
}
tbody td {
padding: 9px 12px; color: hsl(var(--muted-foreground));
padding: 8px 12px; color: hsl(var(--muted-foreground));
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
tbody tr.selected td { color: hsl(var(--foreground)); }
@@ -201,8 +193,8 @@ tbody tr.selected .cell-mono { color: hsl(var(--primary)); }
.empty-state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
flex: 1; padding: 40px;
border: 2px dashed hsl(var(--border)); border-radius: var(--radius);
margin: 20px; text-align: center;
border: 2px dashed hsl(var(--border) / 0.6); border-radius: var(--radius);
margin: 16px; text-align: center;
}
.empty-state-icon {
width: 48px; height: 48px; color: hsl(var(--muted-foreground) / 0.5); margin-bottom: 16px;
@@ -215,17 +207,18 @@ tbody tr.selected .cell-mono { color: hsl(var(--primary)); }
═══════════════════════════════════════════ */
.tabs {
display: flex; border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted)); padding: 0 16px;
background: hsl(var(--card)); padding: 0 16px;
min-height: 38px;
}
.tab {
display: flex; align-items: center; gap: 6px;
padding: 10px 16px; font-size: 12px; font-weight: 600;
padding: 9px 16px; font-size: 12px; font-weight: 600;
color: hsl(var(--muted-foreground)); cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s; user-select: none;
white-space: nowrap;
}
.tab:hover { color: hsl(var(--muted-foreground)); }
.tab:hover { color: hsl(var(--foreground)); }
.tab.active {
color: hsl(var(--foreground)); border-bottom-color: hsl(var(--primary));
}
@@ -236,7 +229,9 @@ tbody tr.selected .cell-mono { color: hsl(var(--primary)); }
/* Detail Sub-Header */
.detail-sub-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; border-bottom: 1px solid hsl(var(--border));
padding: 8px 16px; border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--card));
min-height: 38px;
}
.detail-sub-title { font-size: 12px; font-weight: 600; color: hsl(var(--muted-foreground)); }
.detail-sub-actions { display: flex; gap: 6px; }
+205
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
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -14,8 +14,9 @@ import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Truck, Package,
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight,
Settings2, RotateCcw,
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";
@@ -27,7 +28,6 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
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 DETAIL_TABLE = "sales_order_detail";
const MASTER_TABLE = "sales_order_mng";
@@ -42,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: "규격" },
@@ -55,9 +66,103 @@ const GRID_COLUMNS_CONFIG = [
{ key: "amount", label: "금액" },
{ key: "currency_code", label: "통화" },
{ key: "due_date", label: "납기일" },
];
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = [
{ key: "order_no", label: "수주번호" },
...MASTER_BODY_LAYOUT.map(({ key, label }) => ({ key, label })),
...DETAIL_HEADER_COLS,
{ key: "memo", label: "메모" },
];
// 총 컬럼 수: 체크박스(1) + 화살표(1) + 수주번호(1) + 디테일(11) + 메모(1) = 15
const TOTAL_COLS = 15;
// 헤더 필터 Popover
function HeaderFilterPopover({
colKey, colLabel, uniqueValues, filterValues, onToggle, onClear,
}: {
colKey: string;
colLabel: string;
uniqueValues: string[];
filterValues: Set<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();
@@ -75,7 +180,6 @@ export default function SalesOrderPage() {
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
const [allowPriceEdit, setAllowPriceEdit] = useState(true);
const [orderGroups, setOrderGroups] = useState<Record<string, { master: any; details: any[] }>>({});
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [closingOrders, setClosingOrders] = useState<Set<string>>(new Set());
@@ -110,6 +214,10 @@ export default function SalesOrderPage() {
// 테이블 설정
const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
// 헤더 필터 & 정렬
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null);
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
@@ -257,16 +365,6 @@ export default function SalesOrderPage() {
};
});
// order_no 기준 그룹핑
const grouped: Record<string, { master: any; details: any[] }> = {};
for (const row of data) {
const key = row.order_no || "_no_order";
if (!grouped[key]) {
grouped[key] = { master: row._master || {}, details: [] };
}
grouped[key].details.push(row);
}
setOrderGroups(grouped);
setOrders(data);
setTotalCount(res.data?.data?.total || data.length);
} catch (err) {
@@ -278,6 +376,160 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 디테일 컬럼별 고유값 (디테일 서브헤더 필터용)
const columnUniqueValues = useMemo(() => {
const result: Record<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);
@@ -341,29 +593,19 @@ 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`, {
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 }] },
@@ -376,7 +618,6 @@ export default function SalesOrderPage() {
});
}
}
}
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchOrders();
@@ -646,56 +887,120 @@ export default function SalesOrderPage() {
{/* 데이터 테이블 (트리 구조) */}
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
<div className="h-full overflow-auto">
<Table style={{ minWidth: "1200px" }}>
<Table style={{ minWidth: "1500px" }}>
<colgroup>
<col style={{ width: "40px" }} />
<col style={{ width: "36px" }} />
<col style={{ width: "160px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "160px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "70px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "80px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "120px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "120px" }} />
<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="text-center">
<TableHead
className="text-center cursor-pointer"
onClick={() => {
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
setCheckedIds(allChecked ? [] : allFilteredIds);
}}
>
<Checkbox
checked={orders.length > 0 && checkedIds.length === orders.length}
onCheckedChange={(checked) => setCheckedIds(checked ? orders.map((o) => o.id) : [])}
checked={(() => {
const allFilteredIds = Object.values(filteredOrderGroups).flatMap((g) => g.details.map((d) => d.id));
return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id));
})()}
onCheckedChange={() => {}}
/>
</TableHead>
<TableHead />
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{/* 수주번호 (별도 컬럼) */}
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none">
<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={14} className="py-16 text-center">
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
</TableCell>
</TableRow>
) : Object.keys(orderGroups).length === 0 ? (
) : Object.keys(filteredOrderGroups).length === 0 ? (
<TableRow>
<TableCell colSpan={14} className="py-16 text-center">
<TableCell colSpan={TOTAL_COLS} className="py-16 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ClipboardList className="h-8 w-8 opacity-30" />
<span className="text-sm"> </span>
@@ -703,18 +1008,15 @@ export default function SalesOrderPage() {
</TableCell>
</TableRow>
) : (
Object.entries(orderGroups).map(([orderNo, group]) => {
Object.entries(filteredOrderGroups).map(([orderNo, group]) => {
const isExpanded = expandedOrders.has(orderNo);
const detailIds = group.details.map((d) => d.id);
const allDetailChecked = detailIds.length > 0 && detailIds.every((id) => checkedIds.includes(id));
const someDetailChecked = detailIds.some((id) => checkedIds.includes(id));
const master = group.master;
const totalQty = group.details.reduce((s, d) => s + (parseFloat(d.qty) || 0), 0);
const totalAmount = group.details.reduce((s, d) => s + (parseFloat(d.amount) || 0), 0);
return (
<React.Fragment key={orderNo}>
{/* 마스터 행 */}
{/* 마스터 행 — 마스터 테이블 필드만 표시 */}
<TableRow
style={{ borderTop: "2px solid hsl(var(--border))" }}
className={cn(
@@ -723,7 +1025,6 @@ export default function SalesOrderPage() {
)}
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; });
@@ -735,16 +1036,20 @@ export default function SalesOrderPage() {
}}
onDoubleClick={() => openEditModal(orderNo)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) => {
if (allDetailChecked) return prev.filter((id) => !detailIds.includes(id));
return [...new Set([...prev, ...detailIds])];
});
}}
>
<Checkbox
checked={allDetailChecked}
data-state={someDetailChecked && !allDetailChecked ? "indeterminate" : undefined}
onCheckedChange={(checked) => {
setCheckedIds((prev) => {
if (checked) return [...new Set([...prev, ...detailIds])];
return prev.filter((id) => !detailIds.includes(id));
});
}}
onCheckedChange={() => {}}
/>
</TableCell>
<TableCell className="text-center">
@@ -753,25 +1058,100 @@ export default function SalesOrderPage() {
: <ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</TableCell>
<TableCell className="font-mono whitespace-nowrap">{orderNo}</TableCell>
<TableCell colSpan={4} className="text-muted-foreground truncate max-w-0">
<span className="truncate block">
{/* 수주번호 */}
<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) : ""}
{master.order_date ? ` · ${master.order_date}` : ""}
<span className="ml-2 text-xs opacity-60">({group.details.length})</span>
</span>
</TableCell>
<TableCell className="text-right font-mono whitespace-nowrap">{totalQty ? totalQty.toLocaleString() : ""}</TableCell>
<TableCell />
<TableCell />
<TableCell />
<TableCell className="text-right font-mono whitespace-nowrap">{totalAmount ? totalAmount.toLocaleString() : ""}</TableCell>
<TableCell className="whitespace-nowrap">{master.due_date || ""}</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-[100px]">{master.memo || ""}</span>
<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>
)}
{/* 디테일 행 (펼쳤을 때만) */}
{isExpanded && group.details.map((row, detailIdx) => {
const isClosing = closingOrders.has(orderNo);
@@ -791,20 +1171,21 @@ export default function SalesOrderPage() {
}}
onDoubleClick={() => openEditModal(row.order_no)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
<TableCell
className="text-center cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setCheckedIds((prev) =>
checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
);
}}
/>
>
<Checkbox checked={isChecked} onCheckedChange={() => {}} />
</TableCell>
<TableCell className="relative">
<div className="tree-connector" data-last={detailIdx === group.details.length - 1 ? "true" : "false"} />
</TableCell>
<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>
@@ -814,6 +1195,7 @@ export default function SalesOrderPage() {
<TableCell className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px]">{row.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>
+3 -3
View File
@@ -1068,11 +1068,11 @@ body span.messenger-time {
background-color: hsl(var(--primary)) !important;
}
/* 짝수 행 stripe — 트리 행(master/detail)은 제외 */
[data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):nth-child(even) {
/* 짝수 행 stripe — 트리 행(master/detail)과 선택 행은 제외 */
[data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):not(.row-selected):nth-child(even) {
background-color: hsl(var(--muted) / 0.35);
}
.dark [data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):nth-child(even) {
.dark [data-slot="table-body"] [data-slot="table-row"]:not(.tree-master-row):not(.tree-detail-row):not(.row-selected):nth-child(even) {
background-color: hsl(var(--muted) / 0.18);
}
+32 -6
View File
@@ -139,7 +139,7 @@ function SortableHeaderCell({
style={style}
className={cn(
col.width, col.minWidth,
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative",
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative overflow-hidden",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
@@ -586,7 +586,7 @@ export function EDataTable<T extends Record<string, any> = any>({
<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">
<TableRow className="bg-muted hover:bg-muted h-10">
{/* 체크박스 */}
{showCheckbox && (
<TableHead className="w-10 text-center">
@@ -663,7 +663,7 @@ export function EDataTable<T extends Record<string, any> = any>({
}
const id = getRowId(row, rowKey);
const isSelected = selectedId === id;
const isSelected = selectedId != null && String(selectedId) === String(id);
const isChecked = checkedIds.includes(id);
const highlighted = isSelected || isChecked;
@@ -671,9 +671,9 @@ export function EDataTable<T extends Record<string, any> = any>({
<TableRow
key={id || rowIdx}
className={cn(
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
"cursor-pointer border-l-[3px] border-l-transparent transition-all h-[41px]",
highlighted
? "border-l-primary bg-primary/5"
? "border-l-primary bg-primary/20 dark:bg-primary/15 row-selected"
: "hover:bg-accent"
)}
onClick={() => {
@@ -787,7 +787,33 @@ export function EDataTable<T extends Record<string, any> = any>({
</button>
</div>
<div className="w-[180px]" />
<div className="flex items-center gap-1.5">
<Input
type="number"
min={1}
max={totalPages}
placeholder={String(safePage)}
className="h-7 w-14 text-center text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
const val = parseInt((e.target as HTMLInputElement).value, 10);
if (!isNaN(val) && val >= 1 && val <= totalPages) {
setCurrentPage(val);
(e.target as HTMLInputElement).value = "";
(e.target as HTMLInputElement).blur();
}
}
}}
onBlur={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 1 && val <= totalPages) {
setCurrentPage(val);
}
e.target.value = "";
}}
/>
<span>/ {totalPages} </span>
</div>
</div>
)}
</div>
+1 -1
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}
/>
);
+168
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 "============================================"