대시보드 캐시 오류 수정완료
This commit is contained in:
@@ -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 스타일 기능 툴바 */}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user