Skip to content

Commit ee93ccd

Browse files
SOIVclaude
andcommitted
feat(controls): DataTable 컴포넌트 추가 (P1)
컬럼 정렬(asc/desc/none), 전체 검색, 페이지네이션(10/20/50/100), 커스텀 셀 render 콜백, 로딩 스켈레톤 지원. 모듈에서 DB row 데이터를 테이블 형식으로 표시할 때 사용. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ff2a074 commit ee93ccd

5 files changed

Lines changed: 606 additions & 4 deletions

File tree

docs/v2_FINANCIAL-LEDGER/ui/03-control-backlog.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ Phase 2 이후 모듈/커뮤니티 요청에 따라 점진 확장하기 위한
1919
- `진행중` - 작업 중
2020
- `완료` - `packages/controls`에 실제 컴포넌트 반영 + `ready: true` 확인 완료
2121

22-
> **현재 상태 (2026-04-13 기준):**
22+
> **현재 상태 (2026-04-17 기준):**
2323
> P0/P0.5 전 항목 구현 완료 (`ready: true`). `packages/controls/src/components/`에 React 컴포넌트 반영.
24-
> `packages/controls/src/styles/controls.css`에 라이트/다크 모드 공통 스타일 정의.
24+
> `packages/controls/src/styles/index.css`에 라이트/다크 모드 공통 스타일 정의.
2525
> `global.css` 토큰도 라이트 모드 기본값 + 다크 모드 오버라이드(`[data-theme="dark"]` / `prefers-color-scheme`) 구조로 재설계 완료.
2626
> `apps/web` Settings에서 테마 선택 시 `document.documentElement``data-theme` 적용 및 localStorage 저장 동작.
27+
> P1 DataTable 구현 완료 — 컬럼 정렬(3단계), 전체 검색, 페이지네이션, 커스텀 셀 렌더링 지원.
2728
2829
## P0 (Core 필수)
2930

@@ -55,13 +56,14 @@ Phase 2 이후 모듈/커뮤니티 요청에 따라 점진 확장하기 위한
5556

5657
## P1 (자주 쓰이지만 일부 우선 구현)
5758

58-
| Control | 우선순위 | 1.5 구현상태 | 비고 |
59+
| Control | 우선순위 | 구현상태 | 비고 |
5960
| --- | --- | --- | --- |
61+
| DataTable | P1 | 완료 | 컬럼 정렬(asc/desc/none), 전체 검색, 페이지네이션(10/20/50/100), 커스텀 셀 render, 로딩 스켈레톤 |
6062
| Tabs | P1 | 미착수 | settings/module 화면 분리 |
6163
| Dropdown Menu | P1 | 미착수 | header/user/action menu |
6264
| Tooltip | P1 | 미착수 | helper/explain UX |
6365
| Badge / Tag | P1 | 미착수 | 상태 표기 (active/error 등) |
64-
| Pagination | P1 | 미착수 | table/list 페이지 분할 |
66+
| Pagination | P1 | 미착수 | 독립 사용 페이지네이션 (DataTable 내장과 별개) |
6567
| Date Picker | P1 | 미착수 | 단일/범위 선택 |
6668
| File Uploader | P1 | 미착수 | drag&drop + progress |
6769
| Drawer / Sheet | P1 | 미착수 | 모바일/보조 패널 |
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import { useState, useMemo, type ReactNode, type ChangeEvent } from 'react';
2+
3+
// ─── Types ────────────────────────────────────────────────────────────────────
4+
5+
export type SortDir = 'asc' | 'desc' | null;
6+
7+
export interface TableColumn<T extends Record<string, unknown> = Record<string, unknown>> {
8+
/** row 객체의 키 (중첩 불가, flat key) */
9+
key: string;
10+
/** 헤더에 표시할 레이블 */
11+
label: string;
12+
/** 컬럼 너비 (CSS value). 미지정 시 균등 분배 */
13+
width?: string;
14+
/** 셀 텍스트 정렬. 기본 left */
15+
align?: 'left' | 'center' | 'right';
16+
/** false로 설정하면 정렬 비활성. 기본 true */
17+
sortable?: boolean;
18+
/** 셀 커스텀 렌더링. value = row[key], row = 전체 행 */
19+
render?: (value: unknown, row: T) => ReactNode;
20+
}
21+
22+
export interface DataTableProps<T extends Record<string, unknown> = Record<string, unknown>> {
23+
columns: TableColumn<T>[];
24+
rows: T[];
25+
/** 행 고유 키. string 키 이름 또는 함수. 미지정 시 인덱스 사용 */
26+
rowKey?: string | ((row: T, index: number) => string);
27+
/** 페이지 크기 초기값. 기본 20 */
28+
pageSize?: number;
29+
/** 검색창 표시 여부. 기본 true */
30+
searchable?: boolean;
31+
/** 로딩 중 여부 */
32+
loading?: boolean;
33+
/** 빈 상태 메시지 */
34+
emptyText?: string;
35+
className?: string;
36+
}
37+
38+
// ─── Page size options ────────────────────────────────────────────────────────
39+
40+
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
41+
42+
// ─── Helpers ──────────────────────────────────────────────────────────────────
43+
44+
function cellText(value: unknown): string {
45+
if (value === null || value === undefined) return '';
46+
if (typeof value === 'object') return JSON.stringify(value);
47+
return String(value);
48+
}
49+
50+
function compareValues(a: unknown, b: unknown, dir: 'asc' | 'desc'): number {
51+
const aStr = cellText(a).toLowerCase();
52+
const bStr = cellText(b).toLowerCase();
53+
const aNum = Number(a);
54+
const bNum = Number(b);
55+
56+
let cmp: number;
57+
if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) {
58+
cmp = aNum - bNum;
59+
} else {
60+
cmp = aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
61+
}
62+
return dir === 'asc' ? cmp : -cmp;
63+
}
64+
65+
// ─── Sort icon ────────────────────────────────────────────────────────────────
66+
67+
function SortIcon({ dir }: { dir: SortDir }) {
68+
if (dir === 'asc') return <span className="fs-dt-sort-icon" aria-hidden="true"></span>;
69+
if (dir === 'desc') return <span className="fs-dt-sort-icon" aria-hidden="true"></span>;
70+
return <span className="fs-dt-sort-icon fs-dt-sort-icon-idle" aria-hidden="true"></span>;
71+
}
72+
73+
// ─── Component ────────────────────────────────────────────────────────────────
74+
75+
export function DataTable<T extends Record<string, unknown> = Record<string, unknown>>({
76+
columns,
77+
rows,
78+
rowKey,
79+
pageSize: initialPageSize = 20,
80+
searchable = true,
81+
loading = false,
82+
emptyText = '데이터가 없습니다.',
83+
className = '',
84+
}: DataTableProps<T>) {
85+
const [search, setSearch] = useState('');
86+
const [sortKey, setSortKey] = useState<string | null>(null);
87+
const [sortDir, setSortDir] = useState<SortDir>(null);
88+
const [page, setPage] = useState(1);
89+
const [pageSize, setPageSize] = useState(initialPageSize);
90+
91+
// ── 검색 필터 ────────────────────────────────────────────────
92+
const filtered = useMemo(() => {
93+
if (!search.trim()) return rows;
94+
const q = search.toLowerCase();
95+
return rows.filter((row) =>
96+
columns.some((col) => cellText(row[col.key]).toLowerCase().includes(q)),
97+
);
98+
}, [rows, columns, search]);
99+
100+
// ── 정렬 ─────────────────────────────────────────────────────
101+
const sorted = useMemo(() => {
102+
if (!sortKey || !sortDir) return filtered;
103+
return [...filtered].sort((a, b) =>
104+
compareValues(a[sortKey], b[sortKey], sortDir),
105+
);
106+
}, [filtered, sortKey, sortDir]);
107+
108+
// ── 페이지네이션 ─────────────────────────────────────────────
109+
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
110+
const safePage = Math.min(page, totalPages);
111+
const startIdx = (safePage - 1) * pageSize;
112+
const paged = sorted.slice(startIdx, startIdx + pageSize);
113+
114+
// 검색/정렬 변경 시 1페이지로 이동
115+
const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
116+
setSearch(e.target.value);
117+
setPage(1);
118+
};
119+
120+
const handleSort = (key: string) => {
121+
if (sortKey !== key) {
122+
setSortKey(key);
123+
setSortDir('asc');
124+
} else if (sortDir === 'asc') {
125+
setSortDir('desc');
126+
} else if (sortDir === 'desc') {
127+
setSortKey(null);
128+
setSortDir(null);
129+
}
130+
setPage(1);
131+
};
132+
133+
const handlePageSize = (e: ChangeEvent<HTMLSelectElement>) => {
134+
setPageSize(Number(e.target.value));
135+
setPage(1);
136+
};
137+
138+
// ── 행 키 ────────────────────────────────────────────────────
139+
const getRowKey = (row: T, index: number): string => {
140+
if (!rowKey) return String(index);
141+
if (typeof rowKey === 'function') return rowKey(row, index);
142+
return cellText(row[rowKey]) || String(index);
143+
};
144+
145+
// ── 빈/로딩 상태 ─────────────────────────────────────────────
146+
const isEmpty = !loading && paged.length === 0;
147+
148+
return (
149+
<div className={`fs-dt ${className}`.trim()}>
150+
151+
{/* ── 툴바 ──────────────────────────────────────────────── */}
152+
{searchable && (
153+
<div className="fs-dt-toolbar">
154+
<div className="fs-dt-search-wrap">
155+
<span className="fs-dt-search-icon" aria-hidden="true"></span>
156+
<input
157+
type="search"
158+
className="fs-dt-search"
159+
placeholder="검색..."
160+
value={search}
161+
onChange={handleSearch}
162+
aria-label="테이블 검색"
163+
/>
164+
</div>
165+
<span className="fs-dt-count">
166+
{loading ? '로딩 중...' : `${sorted.length.toLocaleString()}개`}
167+
</span>
168+
</div>
169+
)}
170+
171+
{/* ── 테이블 ────────────────────────────────────────────── */}
172+
<div className="fs-dt-scroll" role="region" aria-label="데이터 테이블">
173+
<table className="fs-dt-table">
174+
<thead className="fs-dt-thead">
175+
<tr>
176+
{columns.map((col) => {
177+
const isSorted = sortKey === col.key;
178+
const canSort = col.sortable !== false;
179+
const dir: SortDir = isSorted ? sortDir : null;
180+
return (
181+
<th
182+
key={col.key}
183+
className={`fs-dt-th${canSort ? ' fs-dt-th-sortable' : ''}${isSorted ? ' fs-dt-th-active' : ''}`}
184+
style={{ width: col.width, textAlign: col.align ?? 'left' }}
185+
onClick={canSort ? () => handleSort(col.key) : undefined}
186+
aria-sort={
187+
isSorted
188+
? dir === 'asc' ? 'ascending' : 'descending'
189+
: canSort ? 'none' : undefined
190+
}
191+
>
192+
<span className="fs-dt-th-inner">
193+
<span className="fs-dt-th-label">{col.label}</span>
194+
{canSort && <SortIcon dir={dir} />}
195+
</span>
196+
</th>
197+
);
198+
})}
199+
</tr>
200+
</thead>
201+
202+
<tbody className="fs-dt-tbody">
203+
{loading ? (
204+
// 로딩 스켈레톤
205+
Array.from({ length: pageSize > 5 ? 5 : pageSize }).map((_, i) => (
206+
<tr key={i} className="fs-dt-tr">
207+
{columns.map((col) => (
208+
<td key={col.key} className="fs-dt-td">
209+
<span className="fs-dt-skeleton" />
210+
</td>
211+
))}
212+
</tr>
213+
))
214+
) : isEmpty ? (
215+
<tr>
216+
<td className="fs-dt-empty" colSpan={columns.length}>
217+
{search ? `"${search}"에 해당하는 결과가 없습니다.` : emptyText}
218+
</td>
219+
</tr>
220+
) : (
221+
paged.map((row, i) => (
222+
<tr key={getRowKey(row, startIdx + i)} className="fs-dt-tr">
223+
{columns.map((col) => {
224+
const val = row[col.key];
225+
return (
226+
<td
227+
key={col.key}
228+
className="fs-dt-td"
229+
style={{ textAlign: col.align ?? 'left' }}
230+
>
231+
{col.render ? col.render(val, row) : cellText(val)}
232+
</td>
233+
);
234+
})}
235+
</tr>
236+
))
237+
)}
238+
</tbody>
239+
</table>
240+
</div>
241+
242+
{/* ── 페이지네이션 ──────────────────────────────────────── */}
243+
{!loading && sorted.length > 0 && (
244+
<div className="fs-dt-foot">
245+
<div className="fs-dt-page-size">
246+
<span className="fs-dt-foot-label"></span>
247+
<select
248+
className="fs-dt-page-select"
249+
value={pageSize}
250+
onChange={handlePageSize}
251+
aria-label="페이지 크기"
252+
>
253+
{PAGE_SIZE_OPTIONS.map((n) => (
254+
<option key={n} value={n}>{n}</option>
255+
))}
256+
</select>
257+
</div>
258+
259+
<span className="fs-dt-page-info">
260+
{(startIdx + 1).toLocaleString()}{Math.min(startIdx + pageSize, sorted.length).toLocaleString()} / {sorted.length.toLocaleString()}
261+
</span>
262+
263+
<div className="fs-dt-page-nav">
264+
<button
265+
type="button"
266+
className="fs-dt-page-btn"
267+
onClick={() => setPage(1)}
268+
disabled={safePage === 1}
269+
aria-label="첫 페이지"
270+
>«</button>
271+
<button
272+
type="button"
273+
className="fs-dt-page-btn"
274+
onClick={() => setPage((p) => Math.max(1, p - 1))}
275+
disabled={safePage === 1}
276+
aria-label="이전 페이지"
277+
></button>
278+
<span className="fs-dt-page-cur">{safePage} / {totalPages}</span>
279+
<button
280+
type="button"
281+
className="fs-dt-page-btn"
282+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
283+
disabled={safePage === totalPages}
284+
aria-label="다음 페이지"
285+
></button>
286+
<button
287+
type="button"
288+
className="fs-dt-page-btn"
289+
onClick={() => setPage(totalPages)}
290+
disabled={safePage === totalPages}
291+
aria-label="마지막 페이지"
292+
>»</button>
293+
</div>
294+
</div>
295+
)}
296+
</div>
297+
);
298+
}

packages/controls/src/components/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export type { EmptyStateProps } from './EmptyState.js';
5757
export { Skeleton } from './Skeleton.js';
5858
export type { SkeletonProps } from './Skeleton.js';
5959

60+
// ─── P1 Controls ──────────────────────────────────────────────
61+
export { DataTable } from './DataTable.js';
62+
export type { DataTableProps, TableColumn, SortDir } from './DataTable.js';
63+
6064
// ─── Control Registry ─────────────────────────────────────────
6165
export type ControlTier = 'p0' | 'p0_5' | 'p1' | 'p2';
6266

@@ -65,6 +69,7 @@ export type ControlName =
6569
| 'button'
6670
| 'checkbox'
6771
| 'combobox'
72+
| 'data-table'
6873
| 'empty-state'
6974
| 'form-field'
7075
| 'input'
@@ -109,4 +114,5 @@ export const CONTROL_DESCRIPTORS: ControlDescriptor[] = [
109114
{ name: 'toast', tier: 'p0_5', ready: true },
110115
{ name: 'empty-state', tier: 'p0_5', ready: true },
111116
{ name: 'skeleton', tier: 'p0_5', ready: true },
117+
{ name: 'data-table', tier: 'p1', ready: true },
112118
];

0 commit comments

Comments
 (0)