대시보드 캐시 오류 수정완료

This commit is contained in:
2026-04-22 01:29:08 +09:00
parent 15c74ae26c
commit c7f00ff2cb
7 changed files with 645 additions and 99 deletions
+7 -2
View File
@@ -85,13 +85,18 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
}, [isSingleMode, singleDashboardId, activeDashboardId, setActiveDashboard]);
// 대시보드 전환 시 카드 로드
// stale guard: 응답 도착 시 이미 다른 대시보드로 전환됐으면 무시 (race 방지)
const loadCards = useCallback(async (dashId: string) => {
try {
const cardList = await getDashboardCards(dashId);
setCards(cardList ?? []);
if (useDashboardStore.getState().activeDashboardId === dashId) {
setCards(cardList ?? []);
}
} catch (err) {
console.error('[Dashboard] Load cards failed:', err);
setCards([]);
if (useDashboardStore.getState().activeDashboardId === dashId) {
setCards([]);
}
}
}, [setCards]);
@@ -17,6 +17,7 @@ const ROW_HEIGHT_PRESETS: Record<TableRowHeight, string> = {
normal: "36px",
relaxed: "44px",
};
const DESIGN_PREVIEW_ROWS = 5;
export interface TableComponentProps extends ComponentRendererProps {
config?: TableConfig;
@@ -109,11 +110,13 @@ export const TableComponent: React.FC<TableComponentProps> = ({
const showToolbar = componentConfig.showToolbar ?? true;
const emptyMessage = componentConfig.emptyMessage ?? "데이터가 없습니다";
// ─── 데이터 fetch (런타임만) ───
// ─── 데이터 fetch ───
const tableData = useTableData({
tableName,
enabled: !isDesignMode && !!tableName,
pageSize: componentConfig.pagination?.pageSize ?? 20,
enabled: !!tableName,
pageSize: isDesignMode
? Math.min(componentConfig.pagination?.pageSize ?? DESIGN_PREVIEW_ROWS, DESIGN_PREVIEW_ROWS)
: componentConfig.pagination?.pageSize ?? 20,
});
// ─── 행 선택 ───
@@ -135,7 +138,9 @@ export const TableComponent: React.FC<TableComponentProps> = ({
// ─── 렌더할 데이터 결정 ───
const rows = isDesignMode
? (columns.length > 0 ? [{}, {}, {}] : []) // 디자인 모드: 빈 목업 3줄
? (tableData.data.length > 0
? tableData.data.slice(0, DESIGN_PREVIEW_ROWS)
: (columns.length > 0 ? [{}, {}, {}] : []))
: tableData.data;
// ─── DOM props 필터 ───
@@ -194,7 +199,8 @@ export const TableComponent: React.FC<TableComponentProps> = ({
<span style={{ fontSize: "11px", color: "hsl(var(--muted-foreground))", fontWeight: 600 }}>
{tableName ? tableName : displayMode.toUpperCase()}
{!isDesignMode && ` · ${tableData.total}`}
{isDesignMode && columns.length > 0 && ` · 컬럼 ${columns.length}`}
{isDesignMode && tableName && ` · 미리보기 ${Math.min(rows.length, DESIGN_PREVIEW_ROWS)}`}
{isDesignMode && !tableName && columns.length > 0 && ` · 컬럼 ${columns.length}`}
</span>
<div style={{ display: "flex", gap: "4px" }}>
{tableData.loading && (
@@ -276,7 +282,9 @@ export const TableComponent: React.FC<TableComponentProps> = ({
{columns.map((col) => (
<td key={col.key} style={{ ...tdStyle, textAlign: col.align ?? "left" }}>
{isDesignMode ? (
<span style={{ color: "hsl(var(--muted-foreground))" }}>...</span>
<span style={{ color: "hsl(var(--muted-foreground))" }}>
{row[col.key] != null ? String(row[col.key]) : "..."}
</span>
) : (
<span>{row[col.key] != null ? String(row[col.key]) : ""}</span>
)}
@@ -289,7 +297,13 @@ export const TableComponent: React.FC<TableComponentProps> = ({
colSpan={columns.length + (showCheckbox ? 1 : 0) || 1}
style={{ padding: "24px", textAlign: "center", color: "hsl(var(--muted-foreground))", fontSize: "11px" }}
>
{tableData.loading ? "로딩 중..." : (tableData.error || emptyMessage)}
{!tableName
? "테이블을 연결하세요"
: columns.length === 0
? "컬럼을 자동 로드하세요"
: tableData.loading
? "로딩 중..."
: (tableData.error || emptyMessage)}
</td>
</tr>
)}
@@ -299,7 +313,7 @@ export const TableComponent: React.FC<TableComponentProps> = ({
const renderFooter = () =>
showFooter && (
<div style={footerStyle}>
<span> {isDesignMode ? 0 : tableData.total}</span>
<span>{isDesignMode ? `미리보기 ${rows.length}` : `${tableData.total}`}</span>
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<button
type="button"
@@ -334,7 +348,11 @@ export const TableComponent: React.FC<TableComponentProps> = ({
</table>
) : (
<div style={{ padding: "24px", textAlign: "center", color: "hsl(var(--muted-foreground))", fontSize: "11px" }}>
{isDesignMode ? "컬럼을 설정하면 테이블이 표시됩니다" : emptyMessage}
{!tableName
? "테이블을 연결하세요"
: isDesignMode
? "컬럼을 자동 로드하면 미리보기가 표시됩니다"
: emptyMessage}
</div>
)}
</div>
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useMemo, useState, useEffect } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TableConfig, TableColumn } from "./types";
/**
@@ -38,6 +38,9 @@ export const TableConfigPanel: React.FC<TableConfigPanelProps> = ({
);
const columns: TableColumn[] = current.columns ?? [];
const connectedTable = (current as any).selectedTable || screenTableName;
const [connectedTableColumns, setConnectedTableColumns] = useState<any[]>([]);
const [loadingConnectedColumns, setLoadingConnectedColumns] = useState(false);
const updateColumn = (idx: number, col: Partial<TableColumn>) => {
const next = columns.map((c, i) => (i === idx ? { ...c, ...col } : c));
@@ -77,10 +80,69 @@ export const TableConfigPanel: React.FC<TableConfigPanelProps> = ({
}));
}, [allDbTables, tables]);
useEffect(() => {
let cancelled = false;
if (!connectedTable) {
setConnectedTableColumns([]);
setLoadingConnectedColumns(false);
return () => {
cancelled = true;
};
}
if (
connectedTable === screenTableName &&
Array.isArray(tableColumns) &&
tableColumns.length > 0
) {
setConnectedTableColumns(tableColumns);
setLoadingConnectedColumns(false);
return () => {
cancelled = true;
};
}
setLoadingConnectedColumns(true);
(async () => {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const cols = await tableTypeApi.getColumns(connectedTable, true);
if (!cancelled) {
setConnectedTableColumns(Array.isArray(cols) ? cols : []);
}
} catch {
if (!cancelled) {
setConnectedTableColumns([]);
}
} finally {
if (!cancelled) {
setLoadingConnectedColumns(false);
}
}
})();
return () => {
cancelled = true;
};
}, [connectedTable, screenTableName, tableColumns]);
const effectiveTableColumns = useMemo(() => {
if (
connectedTable === screenTableName &&
Array.isArray(tableColumns) &&
tableColumns.length > 0
) {
return tableColumns;
}
return connectedTableColumns;
}, [connectedTable, screenTableName, tableColumns, connectedTableColumns]);
// 테이블 컬럼에서 TableColumn 자동 생성
const autoLoadColumns = useCallback(() => {
if (!tableColumns?.length) return;
const newCols: TableColumn[] = tableColumns.map((col: any) => ({
if (!effectiveTableColumns?.length) return;
const newCols: TableColumn[] = effectiveTableColumns.map((col: any) => ({
key: col.columnName || col.column_name,
label: col.columnLabel || col.column_label || col.displayName || col.columnName || col.column_name,
width: undefined,
@@ -89,9 +151,7 @@ export const TableConfigPanel: React.FC<TableConfigPanelProps> = ({
visible: true,
}));
patch({ columns: newCols });
}, [tableColumns, patch]);
const connectedTable = (current as any).selectedTable || screenTableName;
}, [effectiveTableColumns, patch]);
const sel = "border-border bg-background w-full rounded border px-2 py-1 text-xs";
const lbl = "text-muted-foreground mb-1 block text-[0.62rem] font-semibold tracking-wider uppercase";
@@ -102,27 +162,33 @@ export const TableConfigPanel: React.FC<TableConfigPanelProps> = ({
{tableOptions.length > 0 && (
<div className="border-border rounded border p-2">
<label className={lbl}> </label>
<select
<SearchableSelect
value={connectedTable || ""}
onChange={(e) => {
const v = e.target.value;
options={[{ value: "", label: "(선택 안 함)" }, ...tableOptions]}
onChange={(v) => {
onTableChange?.(v);
patch({ selectedTable: v } as any);
patch({ selectedTable: v, columns: [] } as any);
}}
className={sel}
>
<option value="">...</option>
{tableOptions.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{connectedTable && tableColumns && tableColumns.length > 0 && (
placeholder="테이블 선택..."
searchPlaceholder="테이블 검색 (한글/영문)..."
emptyLabel="일치하는 테이블이 없습니다"
/>
{connectedTable && (
<div className="text-muted-foreground mt-1 text-[0.6rem]">
{loadingConnectedColumns
? "컬럼 정보를 불러오는 중..."
: effectiveTableColumns.length > 0
? `컬럼 ${effectiveTableColumns.length}개 준비됨`
: "불러온 컬럼 정보가 없습니다"}
</div>
)}
{connectedTable && effectiveTableColumns.length > 0 && (
<button
type="button"
onClick={autoLoadColumns}
className="border-border hover:bg-accent mt-1.5 w-full rounded border px-2 py-1 text-[0.62rem] font-medium"
>
DB ({tableColumns.length})
DB ({effectiveTableColumns.length})
</button>
)}
</div>
@@ -242,7 +308,11 @@ export const TableConfigPanel: React.FC<TableConfigPanelProps> = ({
{columns.length === 0 && (
<div className="text-muted-foreground border-border rounded border border-dashed p-2 text-center text-[0.6rem]">
{connectedTable
? "위 '자동 로드' 버튼으로 컬럼을 불러오세요"
? loadingConnectedColumns
? "연결된 테이블의 컬럼 정보를 불러오는 중입니다"
: effectiveTableColumns.length > 0
? "위 '자동 로드' 버튼으로 컬럼을 불러오세요"
: "연결된 테이블에서 컬럼 정보를 찾지 못했습니다"
: "테이블을 연결하면 컬럼을 자동 로드할 수 있습니다"}
</div>
)}
@@ -316,4 +386,140 @@ export const TableConfigPanel: React.FC<TableConfigPanelProps> = ({
);
};
// ─── 검색 가능한 드롭다운 ─────────────────────────────────────────────────
// 테이블 수가 많을 때 네이티브 <select> 는 찾기 어려우므로 검색 입력을 가진
// 커스텀 팝오버 드롭다운을 제공한다. 한글 label 과 영문 value 둘 다 검색 대상.
interface SearchableSelectOption {
value: string;
label: string;
}
function SearchableSelect({
value,
options,
onChange,
placeholder = "선택...",
searchPlaceholder = "검색...",
emptyLabel = "결과 없음",
}: {
value: string;
options: SearchableSelectOption[];
onChange: (v: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyLabel?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKey);
};
}, [open]);
useEffect(() => {
if (!open) setQuery("");
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter(
(o) =>
o.label.toLowerCase().includes(q) ||
o.value.toLowerCase().includes(q),
);
}, [options, query]);
const current = options.find((o) => o.value === value);
const triggerCls =
"border-border bg-background w-full rounded border px-2 py-1 text-xs flex items-center justify-between text-left transition-colors hover:bg-accent/40";
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={triggerCls}
aria-haspopup="listbox"
aria-expanded={open}
>
<span
className={
current && current.value
? "truncate"
: "text-muted-foreground truncate"
}
>
{current && current.value ? current.label : placeholder}
</span>
<span className="text-muted-foreground ml-2 flex-shrink-0 text-[0.6rem]">
</span>
</button>
{open && (
<div className="border-border bg-background absolute top-[calc(100%+2px)] right-0 left-0 z-20 flex max-h-64 flex-col overflow-hidden rounded border shadow-lg">
<div className="border-border border-b p-1.5">
<input
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="border-border bg-background w-full rounded border px-2 py-1 text-xs"
/>
</div>
<ul className="flex-1 overflow-y-auto py-0.5" role="listbox">
{filtered.length === 0 ? (
<li className="text-muted-foreground px-2 py-2 text-center text-[0.65rem]">
{emptyLabel}
</li>
) : (
filtered.map((o) => {
const isSelected = o.value === value;
return (
<li key={o.value || "__empty__"} role="option" aria-selected={isSelected}>
<button
type="button"
onClick={() => {
onChange(o.value);
setOpen(false);
}}
className={`flex w-full items-center justify-between px-2 py-1 text-left text-xs transition-colors ${
isSelected
? "bg-accent text-accent-foreground"
: "hover:bg-accent/60"
}`}
>
<span className="truncate">{o.label}</span>
{isSelected && (
<span className="ml-2 flex-shrink-0 text-[0.62rem]">
</span>
)}
</button>
</li>
);
})
)}
</ul>
</div>
)}
</div>
);
}
export default TableConfigPanel;
@@ -418,6 +418,7 @@ interface GroupedData {
const tableColumnCache = new Map<string, { columns: any[]; inputTypes?: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
const DESIGN_PREVIEW_PAGE_SIZE = 10;
const cleanupTableCache = () => {
const now = Date.now();
@@ -1847,7 +1848,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// ========================================
const fetchTableDataInternal = useCallback(async () => {
if (!tableConfig.selectedTable || isDesignMode) {
if (!tableConfig.selectedTable) {
setData([]);
setTotalPages(0);
setTotalItems(0);
@@ -1858,19 +1859,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setError(null);
try {
const page = currentPage;
const pageSize = localPageSize;
const isPreviewMode = isDesignMode;
const page = isPreviewMode ? 1 : currentPage;
const pageSize = isPreviewMode ? Math.min(localPageSize || DESIGN_PREVIEW_PAGE_SIZE, DESIGN_PREVIEW_PAGE_SIZE) : localPageSize;
// 🆕 sortColumn이 없으면 defaultSort 설정을 fallback으로 사용
const sortBy = sortColumn || tableConfig.defaultSort?.columnName || undefined;
const sortOrder = sortColumn ? sortDirection : (tableConfig.defaultSort?.direction || sortDirection);
const search = searchTerm || undefined;
const search = isPreviewMode ? undefined : searchTerm || undefined;
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
const linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
if (splitPanelContext) {
if (!isPreviewMode && splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linked_filters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
@@ -1932,8 +1934,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
if (!isPreviewMode && hasLinkedFiltersConfigured && !hasSelectedLeftData) {
setData([]);
setTotalPages(0);
setTotalItems(0);
setLoading(false);
return;
@@ -1941,8 +1944,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (isRelatedButtonTarget && !relatedButtonFilter) {
if (!isPreviewMode && isRelatedButtonTarget && !relatedButtonFilter) {
setData([]);
setTotalPages(0);
setTotalItems(0);
setLoading(false);
return;
@@ -1950,7 +1954,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// RelatedDataButtons 필터 값 준비
const relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
if (!isPreviewMode && relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
value: relatedButtonFilter.filterValue,
operator: "equals",
@@ -2080,36 +2084,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
// console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
setError(null);
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
// tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음)
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
const labels: Record<string, string> = {};
cols.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
response.data || [],
cols.map((col) => col.columnName),
sortBy ?? null,
sortOrder,
{
filter_conditions: filters,
search_term: search,
visible_columns: cols.map((col) => col.columnName),
column_labels: labels,
current_page: page,
page_size: pageSize,
total_items: response.total || 0,
},
);
}
setData(response?.data || []);
setTotalPages(response?.totalPages || 0);
setTotalItems(response?.total || 0);
setError(null);
if (isPreviewMode) {
return;
}
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
// tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음)
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
const labels: Record<string, string> = {};
cols.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
response?.data || [],
cols.map((col) => col.columnName),
sortBy ?? null,
sortOrder,
{
filter_conditions: filters,
search_term: search,
visible_columns: cols.map((col) => col.columnName),
column_labels: labels,
current_page: page,
page_size: pageSize,
total_items: response?.total || 0,
},
);
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
@@ -2331,6 +2340,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
if (isDesignMode) return;
const isMultiSelect = tableConfig.checkbox?.multiple !== false;
let newSelectedRows: Set<string>;
@@ -2401,6 +2412,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
const handleSelectAll = (checked: boolean) => {
if (isDesignMode) return;
if (checked) {
const allKeys = filteredData.map((row, index) => getRowKey(row, index));
const newSelectedRows = new Set(allKeys);
@@ -2466,6 +2479,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
if (isDesignMode) return;
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) {
@@ -2496,6 +2511,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
if (isDesignMode) return;
e.stopPropagation();
// 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지)
@@ -2550,6 +2567,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용
const handleCellDoubleClick = useCallback(
(rowIndex: number, colIndex: number, columnName: string, value: any) => {
if (isDesignMode) return;
// 체크박스 컬럼은 편집 불가
if (columnName === "__checkbox__") return;
@@ -4401,7 +4420,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
break;
}
},
[editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection],
[editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection, isDesignMode],
);
const getColumnWidth = (column: ColumnConfig) => {
@@ -5554,6 +5573,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
style: componentStyle,
};
if (!tableConfig.selectedTable) {
return (
<div {...filterDOMProps(domProps)}>
<div className="flex h-full min-h-[300px] items-center justify-center px-6 text-center">
<div className="space-y-2">
<div className="text-sm font-medium"> </div>
<div className="text-muted-foreground text-xs">
.
</div>
</div>
</div>
</div>
);
}
// 카드 모드
if (tableConfig.displayMode === "card" && !isDesignMode) {
return (
@@ -5664,6 +5698,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return (
<>
<div {...filterDOMProps(domProps)}>
{isDesignMode && (
<div className="border-border bg-primary/5 text-primary flex items-center justify-between border-b px-3 py-2 text-xs">
<span> </span>
<span>{tableConfig.selectedTable} · {DESIGN_PREVIEW_PAGE_SIZE}</span>
</div>
)}
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
{/* 🆕 DevExpress 스타일 기능 툴바 */}
+6 -1
View File
@@ -41,7 +41,12 @@ export const useDashboardStore = create<DashboardState>()(
setDashboards: (dashboards) => set({ dashboards }),
setActiveDashboard: (id) => set({ activeDashboardId: id, editMode: false }),
setActiveDashboard: (id) =>
set((s) =>
s.activeDashboardId === id
? { activeDashboardId: id, editMode: false }
: { activeDashboardId: id, editMode: false, cards: [] }
),
setCards: (cards) => set({ cards }),
+67 -29
View File
@@ -62,49 +62,53 @@ export type FieldOption = string | { value: string; label: string };
*
* vex의 ColumnConfig(354줄) + FilterConfig + FormField → 이 ~30줄로 통합.
*/
export interface FieldConfig {
// ─── 식별 ───
export interface FieldConfigCore {
/** DB 컬럼명 (필드의 유일 키) */
column: string;
/** 화면에 표시되는 라벨 */
label: string;
// ─── 타입 ───
/** 렌더링 방식을 결정하는 필드 타입 */
type: FieldType;
// ─── 표시 ───
/** 화면에 보이는지 여부 */
visible: boolean;
/** 표시 순서 (작을수록 먼저) */
order: number;
/** 컬럼 너비 (px, 테이블에서 사용) */
width?: number;
/** 텍스트 정렬 */
align?: 'left' | 'center' | 'right';
// ─── 입력 ───
/** 필수 입력 여부 */
required: boolean;
/** 편집 가능 여부 */
editable: boolean;
}
/**
* 공통 표시/입력 속성.
*
* v0.1 기준:
* - `width`, `align` 은 table 쪽 표시 힌트
* - `defaultValue`, `placeholder` 는 form/search 입력 힌트
* - 컴포넌트 전용 레이아웃/동작 옵션은 FieldConfig 가 아니라 각 config 로 분리
*/
export interface FieldConfigCommonOptions {
/** 컬럼 너비 (px, 테이블에서 사용) */
width?: number;
/** 텍스트 정렬 */
align?: 'left' | 'center' | 'right';
/** 기본값 */
defaultValue?: unknown;
/** 입력 힌트 텍스트 */
placeholder?: string;
}
// ─── 타입별 확장 ───
/** select 타입: 선택지 목록
* - string이면 value=label로 해석
* - { value, label } 객체면 value를 저장, label을 표시
* - select 렌더러/검색 파라미터는 항상 value를 저장/전송
* - 테이블 셀은 label을 표시
*/
/**
* 타입별 확장 속성.
*
* 원칙:
* - select 전용은 `options`
* - entity 전용은 `ref`
* - number/date/datetime 표시 힌트는 `format`
* - 계산 규칙은 `computed`
*/
export interface FieldConfigTypeOptions {
/** select 타입: 선택지 목록 */
options?: FieldOption[];
/** entity 타입: FK 참조 정보 */
ref?: FieldRef;
@@ -112,9 +116,14 @@ export interface FieldConfig {
format?: string;
/** 자동 계산 수식 (예: 'quantity * unit_price') */
computed?: string;
}
// ─── 메타 ───
/**
* 메타 속성.
*
* 화면 공통 렌더링 계약보다는 저장/동작 힌트에 가깝다.
*/
export interface FieldConfigMeta {
/** PK 여부 */
pk?: boolean;
/** 시스템 필드 여부 (company_code 등, 폼에서 숨김) */
@@ -125,6 +134,12 @@ export interface FieldConfig {
sortable?: boolean;
}
export interface FieldConfig
extends FieldConfigCore,
FieldConfigCommonOptions,
FieldConfigTypeOptions,
FieldConfigMeta {}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Component — 유일한 컴포넌트 규격
// ─────────────────────────────────────────────────────────────────────────────
@@ -228,11 +243,33 @@ export type DataPortType =
| 'value' // 단일 값: any
| 'params'; // 검색 파라미터: Record<string, any>
/**
* v0.1 예약 포트 이름.
*
* 아직 `name` 자체를 이 union으로 강제하지는 않는다.
* 이유:
* - 기존 코드와 저장 JSON 호환성 유지
* - 사용자 정의 포트 확장 여지 보존
*
* 대신 아래 이름들은 기본 컴포넌트가 우선적으로 사용한다.
*/
export type ReservedDataPortName =
| 'searchParams'
| 'refreshTrigger'
| 'selectedRow'
| 'selectedRows'
| 'loadRow'
| 'formData'
| 'savedRow'
| 'clicked';
/**
* 컴포넌트가 데이터를 주고받는 포트.
*
* output.name → input.name 매칭으로 연결된다.
* 빌더에서 시각적으로 연결을 설정하고, 실행 시 이벤트 버스가 자동 전달한다.
* v0.1 원칙:
* - 포트는 "데이터 계약 이름 + 타입"만 가진다
* - 값 변환/매핑/가공은 포트가 아니라 컴포넌트 내부 또는 별도 액션에서 처리한다
* - 화면 수준 연결은 Connection 이 담당하고, 런타임은 단순 publish/subscribe 브리지다
*/
export interface DataPort {
/** 포트 이름 (예: 'selectedRow', 'searchParams') */
@@ -246,7 +283,8 @@ export interface DataPort {
/**
* 두 컴포넌트 간 DataPort 연결을 나타낸다.
*
* Template.connections 배열에 저장되어 화면 전체의 데이터 흐름을 정의한다.
* v0.1 에서는 단순 연결만 담당한다.
* 즉 `from.port` 값을 `to.port` 로 그대로 전달하며, 중간 매핑/변환 규칙은 없다.
*/
export interface Connection {
/** 연결 고유 ID */
@@ -0,0 +1,233 @@
# FieldConfig / DataPort v0.1
> 작성일: 2026-04-22
> 상태: working draft
> 목적: 컴포넌트를 하나씩 만들면서도 공통 규격이 흔들리지 않게 최소 계약을 먼저 고정한다.
---
## 1. 결정 원칙
1. `FieldConfig` 는 공통 필드 규격만 가진다.
2. 컴포넌트 전용 옵션은 `TableConfig`, `FormConfig`, `SearchConfig` 로 분리한다.
3. `DataPort` 는 데이터 전달 계약만 가진다.
4. 포트 연결은 `Connection` 으로만 표현하고, 중간 변환 규칙은 넣지 않는다.
5. 먼저 `table -> search -> form` 3개를 기준 컴포넌트로 삼아 규격을 검증한다.
---
## 2. FieldConfig v0.1
### 2.1 Core
아래 7개를 코어로 본다.
```ts
column
label
type
visible
order
required
editable
```
이 7개는 `table / form / search` 모두에서 의미가 있어야 한다.
### 2.2 Common Options
아래는 공통 옵션으로 허용하지만 코어는 아니다.
```ts
width
align
defaultValue
placeholder
```
원칙:
- `width`, `align` 은 table 표시 힌트다.
- `defaultValue`, `placeholder` 는 form/search 입력 힌트다.
- 특정 컴포넌트만 강하게 의존하는 옵션은 FieldConfig 에 넣지 않는다.
### 2.3 Type Options
타입별 확장은 여기까지만 둔다.
```ts
options // select
ref // entity
format // number/date/datetime
computed // 계산식
```
원칙:
- `options` 는 select 전용이다.
- `ref` 는 entity 전용이다.
- `format` 은 표시 힌트다.
- `computed` 는 계산 규칙 정의이고, 실제 실행 엔진은 별도 책임이다.
### 2.4 Meta
아래는 메타/동작 힌트로 본다.
```ts
pk
system
searchable
sortable
```
이 필드들은 렌더링 코어라기보다 빌더/저장/런타임 힌트에 가깝다.
---
## 3. 렌더링 계약 v0.1
### table
- `visible``true` 인 필드만 컬럼 후보가 된다.
- `order` 로 기본 순서를 정한다.
- `width`, `align`, `format` 을 우선 사용한다.
- `editable` 은 인라인 편집 가능 여부 판단에 사용한다.
### form
- `visible``true` 이고 `system !== true` 인 필드가 기본 표시 대상이다.
- `required`, `editable`, `defaultValue`, `placeholder` 를 사용한다.
- `computed` 는 readonly 처리와 함께 별도 계산 엔진에서 반영한다.
### search
- 기본적으로 `searchable !== false` 인 필드만 검색 후보로 본다.
- `type` 에 따라 검색 입력 UI 를 바꾼다.
- 상세 배치 방식은 `SearchConfig` 가 가진다.
---
## 4. DataPort v0.1
### 4.1 포트 타입
```ts
row
rows
value
params
```
여기서 멈춘다. `query`, `event`, `schema` 같은 확장은 아직 넣지 않는다.
### 4.2 포트 정의
```ts
interface DataPort {
name: string;
type: 'row' | 'rows' | 'value' | 'params';
connectedTo?: string;
}
```
원칙:
- 포트는 데이터 계약만 가진다.
- 포트 자체에 변환 로직을 넣지 않는다.
- 실제 연결은 `Connection[]` 에서 관리한다.
### 4.3 Connection 정의
```ts
interface Connection {
id: string;
from: { componentId: string; port: string };
to: { componentId: string; port: string };
}
```
원칙:
- `from.port` 값을 `to.port` 로 그대로 전달한다.
- 중간 `mapping`, `transform`, `condition` 은 v0.1 범위에서 제외한다.
- 지금 런타임 구현도 이 단순 브리지 모델과 일치한다.
### 4.4 예약 포트 이름
기본 컴포넌트는 아래 이름을 우선 사용한다.
```ts
searchParams
refreshTrigger
selectedRow
selectedRows
loadRow
formData
savedRow
clicked
```
초기 표준 연결:
- `search.searchParams -> table.searchParams`
- `table.selectedRow -> form.loadRow`
- `form.savedRow -> table.refreshTrigger`
---
## 5. 컴포넌트별 책임 분리
### TableConfig
여기에 둔다.
- 페이지 크기
- 선택 모드
- 체크박스 표시
- 인라인 편집
- 자동 로드
- 기본 정렬
- 툴바 표시
### FormConfig
여기에 둔다.
- 컬럼 수
- 섹션 구성
- 저장 방식
- 저장 후 동작
### SearchConfig
여기에 둔다.
- 자동 검색
- 초기화 버튼
- 날짜 범위 사용
- 배치 방식
즉, `FieldConfig` 는 필드 설명이고, `*Config` 는 컴포넌트 동작 설명이다.
---
## 6. v0.1 에서 하지 않는 것
- FieldConfig 에 컴포넌트별 레이아웃 옵션 넣기
- DataPort 에 변환 규칙 넣기
- Connection 에 조건식/스크립트 넣기
- search 전용 필드 타입 따로 만들기
- form/table/search 별도 필드 규격으로 분기하기
---
## 7. 다음 순서
1. `table` 구현에서 `FieldConfig core` 소비 범위를 검증한다.
2. `search` 구현에서 `searchable`, `type -> input` 규칙을 고정한다.
3. `form` 구현에서 `required`, `editable`, `defaultValue` 계약을 고정한다.
4. 그 다음에야 `computed`, `entity.ref`, `select.options` 를 강화한다.
한 줄로 정리하면:
`FieldConfig 는 필드 공통 설명`, `DataPort 는 데이터 전달 계약`, `컴포넌트 동작은 각 config`.