2c0a97f2ba
- components/builder/* 폐기 (12-grid 미완성 빌더 14개 파일)
- components/template-builder/TemplateBuilder.tsx 신규
(자유배치 + 3뷰 + Zustand 스토어 + 드래그/리사이즈/히스토리/격자)
- admin/builder/page.tsx 진입점 전환 (BuilderLayout → TemplateBuilder)
- 타입 정리: FreePosition / TemplateComponent / ViewConfig / Card /
Dashboard / CardConnection 추가, 레거시(GridPosition/TemplateKind/
DEFAULT_COMPONENT_LAYOUTS/CANVAS_KEYWORDS) @deprecated 표기
- v2-* 마이그레이션 1차:
· 완전: v2-table-list (ResizeObserver), v2-table-search-widget (@container)
· 경량: button/input/select/date/text-display/card-display/aggregation-widget
(withContainerQuery HOC)
- 다크 모드 대응: Tailwind dark: variant 21패턴 71곳 치환
- /test-card-responsive PoC 검증 페이지
세션 후반 버그 픽스 (phase1-log §7):
- test-card-responsive (main) 그룹 밖 이동 (AppLayout 탭 시스템 회피)
- useRegistryPalette default_size {width,height}/{w,h} 포맷 정규화
- dark: variant 중복 체인 정리
검증: (A) 반응형 메커니즘, (B) TemplateBuilder UI 통과
(C) 기존 VEX 화면은 마이그레이션 미완 상태라 Phase 2 이후 개별 진행
스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md
로그: notes/gbpark/2026-04-10-card-engine-phase1-log.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
647 lines
22 KiB
TypeScript
647 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import { RefreshCw, ChevronDown, X, Settings } from 'lucide-react';
|
|
import { getTemplateInfo } from '@/lib/api/template';
|
|
import { getMetaFields } from '@/lib/api/meta';
|
|
import { fcList } from '@/lib/api/fcData';
|
|
import { FcTable, FcForm, FcSearch, FcButton, FcButtonBar, FcPagination } from '@/components/fc';
|
|
import type {
|
|
FieldConfig,
|
|
GridPosition,
|
|
AbsolutePosition,
|
|
TemplateKind,
|
|
} from '@/types/invyone-component';
|
|
import { isGridPosition } from '@/types/invyone-component';
|
|
import { CardMiniView } from './CardMiniView';
|
|
|
|
interface DashboardCardProps {
|
|
card: Record<string, any>;
|
|
editMode: boolean;
|
|
onRemove: (cardId: string) => void;
|
|
onToggleCollapse: (cardId: string) => void;
|
|
onOpenSettings?: (cardId: string) => void;
|
|
}
|
|
|
|
/**
|
|
* DashboardCard — Template 기반 렌더러 (2026-04-10 재설계)
|
|
* - kind: 'business' → 12-col grid + @container 카드 너비 반응형
|
|
* - kind: 'canvas' → absolute 자유배치 (control/flow 등 예외)
|
|
* - 반응형 분기는 GridComponent가 CSS 변수로 주입, @container 쿼리가 처리
|
|
*/
|
|
export function DashboardCard({
|
|
card,
|
|
editMode,
|
|
onRemove,
|
|
onToggleCollapse,
|
|
onOpenSettings,
|
|
}: DashboardCardProps) {
|
|
const cardId = card.card_id ?? card.CARD_ID;
|
|
const templateId = card.template_id ?? card.TEMPLATE_ID;
|
|
const templateName = card.template_name ?? card.TEMPLATE_NAME ?? '템플릿';
|
|
const templateCategory = card.template_category ?? card.TEMPLATE_CATEGORY ?? '';
|
|
const primaryTable = card.primary_table ?? card.PRIMARY_TABLE ?? '';
|
|
const isCollapsed = card.is_collapsed ?? card.IS_COLLAPSED ?? false;
|
|
|
|
// ─── Template 상태 ───
|
|
const [fields, setFields] = useState<FieldConfig[]>([]);
|
|
const [components, setComponents] = useState<Record<string, any>[]>([]);
|
|
const [connections, setConnections] = useState<Record<string, any>[]>([]);
|
|
const [templateKind, setTemplateKind] = useState<TemplateKind | null>(null);
|
|
const [templateLoaded, setTemplateLoaded] = useState(false);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
// ─── 데이터 상태 ───
|
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(20);
|
|
const [searchParams, setSearchParams] = useState<Record<string, any>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedRow, setSelectedRow] = useState<Record<string, any> | null>(null);
|
|
|
|
const mountedRef = useRef(true);
|
|
|
|
// ─── Template 로드 ───
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
if (!primaryTable && !templateId) return;
|
|
|
|
const loadTemplate = async () => {
|
|
setLoadError(null);
|
|
try {
|
|
// 1순위: Template (빌더에서 만든 컴포넌트 배치 + fields)
|
|
if (templateId) {
|
|
const tpl = await getTemplateInfo(templateId);
|
|
if (mountedRef.current && tpl) {
|
|
const tplFields: FieldConfig[] = Array.isArray(tpl.fields) ? tpl.fields : [];
|
|
// views는 list/create/edit이 있고 각자 components 배열
|
|
const listView = tpl.views?.list ?? {};
|
|
const tplComponents: Record<string, any>[] = Array.isArray(listView.components)
|
|
? listView.components
|
|
: [];
|
|
const tplConnections: Record<string, any>[] = Array.isArray(tpl.connections)
|
|
? tpl.connections
|
|
: [];
|
|
|
|
// Template에 fields가 있으면 그대로, 없으면 DB 메타 fallback
|
|
if (tplFields.length > 0) {
|
|
setFields(tplFields);
|
|
} else if (primaryTable) {
|
|
const meta = await getMetaFields(primaryTable);
|
|
if (mountedRef.current) setFields(meta?.fields ?? []);
|
|
}
|
|
|
|
setComponents(tplComponents);
|
|
setConnections(tplConnections);
|
|
// kind 가 없으면 null로 두고 fallback 렌더 — 레거시 변환은 빌더에서 처리
|
|
setTemplateKind((tpl.kind as TemplateKind) ?? null);
|
|
setTemplateLoaded(true);
|
|
return;
|
|
}
|
|
}
|
|
// 2순위 (fallback): Template 없으면 DB 메타로 기본 카드만 표시
|
|
if (primaryTable) {
|
|
const meta = await getMetaFields(primaryTable);
|
|
if (mountedRef.current && meta?.fields) {
|
|
setFields(meta.fields);
|
|
setComponents([]); // 컴포넌트 배치 없음 → 기본 렌더
|
|
setConnections([]);
|
|
setTemplateKind(null);
|
|
setTemplateLoaded(true);
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
console.error(`[DashboardCard] Template/fields 로드 실패:`, err);
|
|
if (mountedRef.current) {
|
|
setLoadError(err?.message ?? '템플릿 로드 실패');
|
|
}
|
|
}
|
|
};
|
|
loadTemplate();
|
|
|
|
return () => {
|
|
mountedRef.current = false;
|
|
};
|
|
}, [primaryTable, templateId]);
|
|
|
|
// ─── 데이터 조회 ───
|
|
const loadData = useCallback(async () => {
|
|
if (!primaryTable || !templateLoaded) return;
|
|
setLoading(true);
|
|
try {
|
|
const result = await fcList({
|
|
tableName: primaryTable,
|
|
page,
|
|
size: pageSize,
|
|
...searchParams,
|
|
});
|
|
if (mountedRef.current) {
|
|
setData(result?.data ?? result?.list ?? []);
|
|
setTotalCount(result?.total ?? result?.total_count ?? 0);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[DashboardCard] 데이터 조회 실패:`, err);
|
|
} finally {
|
|
if (mountedRef.current) setLoading(false);
|
|
}
|
|
}, [primaryTable, templateLoaded, page, pageSize, searchParams]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// ─── DataPort 콜백 ───
|
|
const handleSearch = useCallback((params: Record<string, any>) => {
|
|
setSearchParams(params);
|
|
setPage(1);
|
|
}, []);
|
|
|
|
const handlePageChange = useCallback(({ page: p, size }: { page: number; size: number }) => {
|
|
setPage(p);
|
|
setPageSize(size);
|
|
}, []);
|
|
|
|
const handleRowSelect = useCallback((row: Record<string, any>) => {
|
|
setSelectedRow(row);
|
|
}, []);
|
|
|
|
// ─── 컴포넌트 정렬 ───
|
|
// grid(business) → row / col 기준, canvas → y / x 기준
|
|
const sortedComponents = useMemo(() => {
|
|
return [...components].sort((a, b) => {
|
|
const pa = a.position ?? {};
|
|
const pb = b.position ?? {};
|
|
if (isGridPosition(pa) && isGridPosition(pb)) {
|
|
return (pa.row ?? 0) - (pb.row ?? 0) || (pa.col ?? 0) - (pb.col ?? 0);
|
|
}
|
|
// absolute fallback (기존 {x,y,w,h} 데이터 호환)
|
|
return ((pa as any).y ?? 0) - ((pb as any).y ?? 0);
|
|
});
|
|
}, [components]);
|
|
|
|
// business kind 여부 — 명시된 kind를 우선하고, 없으면 position 형태로 추정
|
|
const effectiveKind: TemplateKind = useMemo(() => {
|
|
if (templateKind === 'business' || templateKind === 'canvas') return templateKind;
|
|
// kind 미지정 Template — grid position 데이터면 business, 그 외는 canvas로 fallback 렌더
|
|
const sample = components.find((c) => c.position != null)?.position;
|
|
if (sample && isGridPosition(sample)) return 'business';
|
|
return 'canvas';
|
|
}, [templateKind, components]);
|
|
|
|
return (
|
|
<div className={`dash-card${isCollapsed ? ' collapsed' : ''}`}>
|
|
{/* 카드 헤더 */}
|
|
<div className="dash-card-head">
|
|
<div className="dash-card-head-l">
|
|
<div className="dash-card-icon">📋</div>
|
|
<div className="dash-card-title">{templateName}</div>
|
|
{templateCategory && <div className="dash-card-bdg">{templateCategory}</div>}
|
|
</div>
|
|
<div className="dash-card-head-r">
|
|
<button
|
|
className="dash-card-btn"
|
|
title="새로고침"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
loadData();
|
|
}}
|
|
>
|
|
<RefreshCw size={13} />
|
|
</button>
|
|
{onOpenSettings && (
|
|
<button
|
|
className="dash-card-btn"
|
|
title="설정"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onOpenSettings(cardId);
|
|
}}
|
|
>
|
|
<Settings size={13} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className="dash-card-btn"
|
|
title="접기/펴기"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleCollapse(cardId);
|
|
}}
|
|
>
|
|
<ChevronDown
|
|
size={13}
|
|
style={{
|
|
transform: isCollapsed ? 'rotate(180deg)' : 'none',
|
|
transition: 'transform .25s',
|
|
}}
|
|
/>
|
|
</button>
|
|
{editMode && (
|
|
<button
|
|
className="dash-card-btn danger"
|
|
title="카드 삭제"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove(cardId);
|
|
}}
|
|
>
|
|
<X size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 본문 — container query 카드 */}
|
|
<div className="dash-card-body">
|
|
{loadError ? (
|
|
<div className="dash-card-error">⚠ {loadError}</div>
|
|
) : !templateLoaded ? (
|
|
<div className="dash-card-loading">템플릿 로딩 중...</div>
|
|
) : sortedComponents.length === 0 ? (
|
|
// Template에 컴포넌트 배치 없음 → 기본 검색+테이블+페이지네이션
|
|
<DefaultCardContent
|
|
fields={fields}
|
|
data={data}
|
|
loading={loading}
|
|
totalCount={totalCount}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
onSearch={handleSearch}
|
|
onRowSelect={handleRowSelect}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
) : effectiveKind === 'canvas' ? (
|
|
// canvas kind — 자유배치, 반응형 없음 (control/flow 류)
|
|
<div className="dash-card-canvas-wrapper">
|
|
<div className="dash-card-canvas">
|
|
{sortedComponents.map((comp) => (
|
|
<AbsoluteComponent
|
|
key={comp.id}
|
|
component={comp}
|
|
fields={fields}
|
|
data={data}
|
|
loading={loading}
|
|
totalCount={totalCount}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
selectedRow={selectedRow}
|
|
onSearch={handleSearch}
|
|
onRowSelect={handleRowSelect}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// business kind — 12-col grid + @container 카드 너비 반응형
|
|
<div className="dash-card-grid">
|
|
{sortedComponents.map((comp) => (
|
|
<GridComponent
|
|
key={comp.id}
|
|
component={comp}
|
|
fields={fields}
|
|
data={data}
|
|
loading={loading}
|
|
totalCount={totalCount}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
selectedRow={selectedRow}
|
|
onSearch={handleSearch}
|
|
onRowSelect={handleRowSelect}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 접힌 상태: 미니 뷰 */}
|
|
<CardMiniView templateName={templateName} category={templateCategory} tableName={primaryTable} />
|
|
|
|
{/* 리사이즈 핸들 */}
|
|
<div className="dash-resize-handle" data-resize="true" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// 컴포넌트별 렌더러 — business(grid) 와 canvas(absolute)
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
interface ComponentRendererProps {
|
|
component: Record<string, any>;
|
|
fields: FieldConfig[];
|
|
data: Record<string, any>[];
|
|
loading: boolean;
|
|
totalCount: number;
|
|
page: number;
|
|
pageSize: number;
|
|
selectedRow: Record<string, any> | null;
|
|
onSearch: (params: Record<string, any>) => void;
|
|
onRowSelect: (row: Record<string, any>) => void;
|
|
onPageChange: (p: { page: number; size: number }) => void;
|
|
}
|
|
|
|
/**
|
|
* GridComponent — business kind 전용.
|
|
* col/row 및 responsive(narrow/normal/wide) 전부를 CSS 변수로 주입.
|
|
* @container 쿼리에서 col/row를 동시에 오버라이드해야 responsive.row가 실제로 적용됨.
|
|
*/
|
|
function GridComponent(props: ComponentRendererProps) {
|
|
const { component } = props;
|
|
const pos = (component.position ?? {}) as GridPosition;
|
|
|
|
const toRowVal = (r?: number): string | number => (r != null ? r : 'auto');
|
|
const toSpanVal = (s?: number): number => s ?? 1;
|
|
|
|
const r = pos.responsive ?? {};
|
|
const style: React.CSSProperties = {
|
|
'--col': pos.col ?? 1,
|
|
'--col-span': pos.colSpan ?? 12,
|
|
'--row': toRowVal(pos.row),
|
|
'--row-span': toSpanVal(pos.rowSpan),
|
|
|
|
'--col-narrow': r.narrow?.col ?? pos.col ?? 1,
|
|
'--col-span-narrow': r.narrow?.colSpan ?? pos.colSpan ?? 12,
|
|
'--row-narrow': toRowVal(r.narrow?.row ?? pos.row),
|
|
'--row-span-narrow': toSpanVal(r.narrow?.rowSpan ?? pos.rowSpan),
|
|
|
|
'--col-normal': r.normal?.col ?? pos.col ?? 1,
|
|
'--col-span-normal': r.normal?.colSpan ?? pos.colSpan ?? 12,
|
|
'--row-normal': toRowVal(r.normal?.row ?? pos.row),
|
|
'--row-span-normal': toSpanVal(r.normal?.rowSpan ?? pos.rowSpan),
|
|
|
|
'--col-wide': r.wide?.col ?? pos.col ?? 1,
|
|
'--col-span-wide': r.wide?.colSpan ?? pos.colSpan ?? 12,
|
|
'--row-wide': toRowVal(r.wide?.row ?? pos.row),
|
|
'--row-span-wide': toSpanVal(r.wide?.rowSpan ?? pos.rowSpan),
|
|
} as React.CSSProperties;
|
|
|
|
return (
|
|
<div className="tpl-component" data-type={component.type} style={style}>
|
|
{renderByType(props)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* AbsoluteComponent — canvas kind 전용. position.x/y/w/h 직접 렌더.
|
|
*/
|
|
function AbsoluteComponent(props: ComponentRendererProps) {
|
|
const { component } = props;
|
|
const pos = (component.position ?? { x: 0, y: 0, w: 200, h: 100 }) as AbsolutePosition;
|
|
|
|
const style: React.CSSProperties = {
|
|
left: pos.x,
|
|
top: pos.y,
|
|
width: pos.w,
|
|
height: pos.h,
|
|
};
|
|
|
|
return (
|
|
<div className="tpl-component" data-type={component.type} style={style}>
|
|
{renderByType(props)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderByType(props: ComponentRendererProps) {
|
|
const {
|
|
component,
|
|
fields,
|
|
data,
|
|
loading,
|
|
totalCount,
|
|
page,
|
|
selectedRow,
|
|
onSearch,
|
|
onRowSelect,
|
|
onPageChange,
|
|
} = props;
|
|
const config = component.config ?? {};
|
|
const type = component.type;
|
|
|
|
switch (type) {
|
|
case 'table':
|
|
return (
|
|
<FcTable
|
|
fields={fields}
|
|
data={data}
|
|
loading={loading}
|
|
config={config}
|
|
onRowSelect={onRowSelect}
|
|
/>
|
|
);
|
|
|
|
case 'search':
|
|
return <FcSearch fields={fields} onSearch={onSearch} config={config} />;
|
|
|
|
case 'form':
|
|
return <FcForm fields={fields} config={config} loadRow={selectedRow ?? undefined} />;
|
|
|
|
case 'button':
|
|
return <FcButton config={config} />;
|
|
|
|
case 'button-bar':
|
|
return <FcButtonBar config={config} />;
|
|
|
|
case 'pagination':
|
|
return <FcPagination total={totalCount} page={page} config={config} onPageChange={onPageChange} />;
|
|
|
|
case 'title': {
|
|
const titleCfg = config as any;
|
|
return (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent:
|
|
titleCfg.align === 'center' ? 'center' : titleCfg.align === 'right' ? 'flex-end' : 'flex-start',
|
|
color: 'var(--v5-text)',
|
|
fontSize: titleCfg.fontSize ?? '0.85rem',
|
|
fontWeight: titleCfg.fontWeight ?? '700',
|
|
padding: '0 0.4rem',
|
|
}}
|
|
>
|
|
{titleCfg.text ?? component.label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'divider': {
|
|
const dCfg = config as any;
|
|
return (
|
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}>
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
borderTop: `1px ${dCfg.style ?? 'solid'} var(--v5-border)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'stats': {
|
|
const sCfg = config as any;
|
|
const items = Array.isArray(sCfg.items) ? sCfg.items : [];
|
|
return (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'grid',
|
|
gridTemplateColumns: items.length > 0 ? `repeat(${items.length}, 1fr)` : '1fr',
|
|
gap: '.4rem',
|
|
padding: '.3rem',
|
|
}}
|
|
>
|
|
{items.length === 0 ? (
|
|
<div
|
|
style={{
|
|
fontSize: '.55rem',
|
|
color: 'var(--v5-text-muted)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
통계 항목 없음
|
|
</div>
|
|
) : (
|
|
items.map((item: Record<string, any>, i: number) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
border: '1px solid var(--v5-glass-border)',
|
|
borderRadius: 8,
|
|
padding: '.4rem .55rem',
|
|
background: 'var(--v5-glass)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: '.5rem',
|
|
fontWeight: 700,
|
|
color: 'var(--v5-text-muted)',
|
|
textTransform: 'uppercase',
|
|
}}
|
|
>
|
|
{item.label}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '1.1rem',
|
|
fontWeight: 800,
|
|
color: 'var(--v5-text)',
|
|
marginTop: '.15rem',
|
|
}}
|
|
>
|
|
{computeAggregation(data, item.column, item.aggregation)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
default:
|
|
return (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
border: '1px dashed var(--v5-border)',
|
|
borderRadius: 6,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'var(--v5-text-muted)',
|
|
fontSize: '.55rem',
|
|
}}
|
|
>
|
|
{type ?? 'unknown'}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Fallback — Template에 컴포넌트 배치가 없을 때 기본 카드 (검색+테이블+페이지네이션)
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
interface DefaultCardContentProps {
|
|
fields: FieldConfig[];
|
|
data: Record<string, any>[];
|
|
loading: boolean;
|
|
totalCount: number;
|
|
page: number;
|
|
pageSize: number;
|
|
onSearch: (params: Record<string, any>) => void;
|
|
onRowSelect: (row: Record<string, any>) => void;
|
|
onPageChange: (p: { page: number; size: number }) => void;
|
|
}
|
|
|
|
function DefaultCardContent({
|
|
fields,
|
|
data,
|
|
loading,
|
|
totalCount,
|
|
page,
|
|
pageSize,
|
|
onSearch,
|
|
onRowSelect,
|
|
onPageChange,
|
|
}: DefaultCardContentProps) {
|
|
const visibleFields = fields.filter((f) => f.visible && !f.system);
|
|
const searchableFields = fields.filter((f) => f.searchable && !f.system);
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '.35rem', height: '100%' }}>
|
|
{searchableFields.length > 0 && (
|
|
<FcSearch
|
|
fields={fields}
|
|
onSearch={onSearch}
|
|
config={{ layout: 'inline', autoSearch: false, dateRangeEnabled: true, showResetButton: true }}
|
|
/>
|
|
)}
|
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
|
<FcTable fields={visibleFields} data={data} loading={loading} onRowSelect={onRowSelect} />
|
|
</div>
|
|
{totalCount > 0 && (
|
|
<FcPagination total={totalCount} page={page} pageSize={pageSize} onPageChange={onPageChange} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// 통계 집계 헬퍼
|
|
// ─────────────────────────────────────────────────────────
|
|
function computeAggregation(
|
|
data: Record<string, any>[],
|
|
column: string,
|
|
aggregation: 'count' | 'sum' | 'avg'
|
|
): string {
|
|
if (!data || data.length === 0) return '0';
|
|
if (aggregation === 'count') return String(data.length);
|
|
const nums = data
|
|
.map((row) => Number(row[column]))
|
|
.filter((n) => !isNaN(n));
|
|
if (nums.length === 0) return '0';
|
|
if (aggregation === 'sum') {
|
|
return nums.reduce((a, b) => a + b, 0).toLocaleString('ko-KR');
|
|
}
|
|
if (aggregation === 'avg') {
|
|
return Math.round(nums.reduce((a, b) => a + b, 0) / nums.length).toLocaleString('ko-KR');
|
|
}
|
|
return '0';
|
|
}
|