a0c9d9a0ab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
254 lines
7.7 KiB
TypeScript
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;
|
|
}
|
|
}
|