Files
invyone/frontend/components/dash/DashboardCard.tsx
T
gbpark 2c0a97f2ba Phase 1: INVYONE 카드 엔진 토대 정리
- 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>
2026-04-11 03:08:06 +09:00

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