Files
invyone/frontend/components/dash/TemplateRenderer.tsx
T
gbpark a0c9d9a0ab 화면 디자이너 제작
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:28:28 +09:00

254 lines
7.7 KiB
TypeScript

'use client';
import { FcTable, FcSearch, FcButton } from '@/components/fc';
import type {
Template,
TemplateComponent,
FieldConfig,
ButtonConfig,
} from '@/types/invyone-component';
/**
* Template.views.list.components 를 세로 스택(flex-column) 으로 렌더하는
* 런타임 컴포넌트.
*
* ── 레이아웃 원칙 ──────────────────────────────────────────────
* 카드 내부는 항상 자동 레이아웃: 컴포넌트를 `order` 로 정렬하고, 같은
* `row` 키를 가진 연속 블록은 flex-row 로 묶는다. 각 블록은 `flex-1
* min-w-0` 로 가로폭을 분배받고, 행은 `flex-wrap` 이라 카드 폭이 좁아
* 지면 자동으로 줄바꿈된다. px 좌표는 일절 사용하지 않는다.
*
* 레퍼런스: `frontend/app/test-card-responsive/page.tsx` (Phase 1 반응형
* 검증 구현).
*
* 공유 상태(data, selectedRow, searchParams 등) 는 DashboardCard 에서
* 관리하고 `context` 로 전달받는다.
*/
export interface TemplateRenderContext {
fields: FieldConfig[];
data: Record<string, any>[];
loading: boolean;
selectedRow: Record<string, any> | null;
totalCount: number;
page: number;
pageSize: number;
onSearch: (params: Record<string, any>) => void;
onRowSelect: (row: Record<string, any>) => void;
onPageChange: (args: { page: number; size: number }) => void;
onAdd: () => void;
onEdit: () => void;
onDelete: () => void;
}
interface TemplateRendererProps {
template: Template;
context: TemplateRenderContext;
}
export function TemplateRenderer({ template, context }: TemplateRendererProps) {
const rawComponents = (template.views as any)?.list?.components ?? [];
if (!Array.isArray(rawComponents) || rawComponents.length === 0) {
return <EmptyTemplate />;
}
const normalized = normalizeBlocks(rawComponents as any[]);
const rows = groupByRow(normalized);
return (
<div className="flex h-full w-full flex-col gap-2 overflow-auto p-2">
{rows.map((row, i) => (
<div key={i} className="flex w-full flex-row flex-wrap gap-2">
{row.map((block) => (
<div key={block.id} className="min-w-0 flex-1">
<ComponentSwitch block={block} context={context} />
</div>
))}
</div>
))}
</div>
);
}
/**
* 구 포맷(FreePosition 기반) 으로 저장된 블록을 최소 호환 처리:
* `order` 가 없으면 배열 인덱스를 그대로 부여해 최소한 순서만 유지한다.
* 구 데이터는 빌더에서 새로 저장하는 순간 order 포맷으로 이관된다.
*/
function normalizeBlocks(raw: any[]): TemplateComponent[] {
return raw.map((c, i) => ({
...c,
order: typeof c?.order === 'number' ? c.order : i,
row: typeof c?.row === 'number' ? c.row : undefined,
config: c?.config ?? {},
})) as TemplateComponent[];
}
/**
* `order` 로 정렬한 뒤 `row` 키가 같은 연속 블록을 같은 행으로 묶는다.
* undefined row 는 항상 단독 행으로 취급한다.
*/
function groupByRow(blocks: TemplateComponent[]): TemplateComponent[][] {
const sorted = [...blocks].sort((a, b) => a.order - b.order);
const result: TemplateComponent[][] = [];
let current: TemplateComponent[] = [];
let currentKey: number | undefined = undefined;
const flush = () => {
if (current.length > 0) {
result.push(current);
current = [];
currentKey = undefined;
}
};
for (const block of sorted) {
if (block.row === undefined) {
flush();
result.push([block]);
continue;
}
if (currentKey === block.row) {
current.push(block);
} else {
flush();
current = [block];
currentKey = block.row;
}
}
flush();
return result;
}
function EmptyTemplate() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 py-8 text-center">
<div className="text-4xl opacity-40">📋</div>
<div className="text-sm font-medium text-slate-600 dark:text-slate-300">
릿
</div>
<div className="text-xs text-slate-500 opacity-80 dark:text-slate-400">
</div>
</div>
);
}
function ComponentSwitch({
block,
context,
}: {
block: TemplateComponent;
context: TemplateRenderContext;
}) {
const componentId = block.componentId;
const config = (block.config ?? {}) as Record<string, any>;
switch (componentId) {
case 'v2-table-list':
return (
<FcTable
fields={context.fields}
data={context.data}
loading={context.loading}
onRowSelect={context.onRowSelect}
config={config}
/>
);
case 'v2-table-search-widget':
return (
<FcSearch
fields={context.fields}
onSearch={context.onSearch}
config={config}
/>
);
case 'v2-button-primary': {
const buttonConfig: ButtonConfig = {
text: config.text ?? '버튼',
actionType: config.actionType ?? 'save',
variant: config.variant ?? 'default',
confirm: config.confirm,
};
const handler = resolveActionHandler(buttonConfig.actionType, context);
const needsRow =
buttonConfig.actionType === 'edit' ||
buttonConfig.actionType === 'delete';
const disabled = needsRow && !context.selectedRow;
return (
<div className="flex w-full items-center">
<FcButton config={buttonConfig} onClick={handler} disabled={disabled} />
</div>
);
}
case 'v2-text-display':
return (
<div
className="flex w-full items-center px-2 text-slate-700 dark:text-slate-200"
style={{
fontSize: config.fontSize ?? '0.85rem',
fontWeight: config.fontWeight ?? 600,
textAlign: (config.align ?? 'left') as any,
justifyContent:
config.align === 'center'
? 'center'
: config.align === 'right'
? 'flex-end'
: 'flex-start',
}}
>
{config.text ?? ''}
</div>
);
case 'v2-aggregation-widget': {
const label = config.label ?? 'KPI';
const value =
config.value ??
(typeof context.totalCount === 'number' ? context.totalCount : '—');
return (
<div className="flex w-full flex-col items-center justify-center gap-1 rounded border border-slate-200 bg-slate-50 p-2 dark:border-slate-700 dark:bg-slate-900">
<div className="text-[10px] uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</div>
<div className="text-xl font-bold text-indigo-600 dark:text-indigo-300">
{value}
</div>
</div>
);
}
default:
return (
<div className="flex w-full flex-col items-center justify-center gap-1 rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center dark:border-slate-700 dark:bg-slate-900">
<div className="font-mono text-[11px] text-slate-600 dark:text-slate-300">
{componentId}
</div>
<div className="text-[9px] text-slate-500 opacity-80 dark:text-slate-400">
Phase 2.2
</div>
</div>
);
}
}
function resolveActionHandler(
actionType: string | undefined,
context: TemplateRenderContext,
): (() => void) | undefined {
switch (actionType) {
case 'add':
return context.onAdd;
case 'edit':
return context.onEdit;
case 'delete':
return context.onDelete;
default:
return undefined;
}
}