ERP-node/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx

1440 lines
42 KiB
TypeScript
Raw Normal View History

"use client";
/**
* pop-card-list (V2 - )
*
* 3 :
* [] -
* [ 릿] - // +
* [ ] - ///
*/
import React, { useState, useEffect } from "react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
PopCardListConfig,
CardListDataSource,
CardTemplateConfig,
CardHeaderConfig,
CardImageConfig,
CardBodyConfig,
CardFieldBinding,
CardColumnJoin,
CardColumnFilter,
CardSize,
CardLayoutMode,
FilterOperator,
} from "../types";
import {
CARD_SIZE_LABELS,
CARD_LAYOUT_MODE_LABELS,
DEFAULT_CARD_IMAGE,
} from "../types";
import {
fetchTableList,
fetchTableColumns,
type TableInfo,
type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher";
// ===== Props =====
interface ConfigPanelProps {
config: PopCardListConfig | undefined;
onUpdate: (config: PopCardListConfig) => void;
}
// ===== 기본값 =====
const DEFAULT_DATA_SOURCE: CardListDataSource = {
tableName: "",
};
const DEFAULT_HEADER: CardHeaderConfig = {
codeField: undefined,
titleField: undefined,
};
const DEFAULT_IMAGE: CardImageConfig = {
enabled: true,
imageColumn: undefined,
defaultImage: DEFAULT_CARD_IMAGE,
};
const DEFAULT_BODY: CardBodyConfig = {
fields: [],
};
const DEFAULT_TEMPLATE: CardTemplateConfig = {
header: DEFAULT_HEADER,
image: DEFAULT_IMAGE,
body: DEFAULT_BODY,
};
const DEFAULT_CONFIG: PopCardListConfig = {
dataSource: DEFAULT_DATA_SOURCE,
cardTemplate: DEFAULT_TEMPLATE,
layoutMode: "grid",
cardsPerRow: 3,
cardSize: "medium",
};
// ===== 색상 옵션 =====
const COLOR_OPTIONS = [
{ value: "__default__", label: "기본" },
{ value: "#ef4444", label: "빨간색" },
{ value: "#f97316", label: "주황색" },
{ value: "#eab308", label: "노란색" },
{ value: "#22c55e", label: "초록색" },
{ value: "#3b82f6", label: "파란색" },
{ value: "#8b5cf6", label: "보라색" },
{ value: "#6b7280", label: "회색" },
];
// ===== 메인 컴포넌트 =====
export function PopCardListConfigPanel({ config, onUpdate }: ConfigPanelProps) {
// 3탭 구조: 테이블 선택 → 카드 템플릿 → 데이터 소스
const [activeTab, setActiveTab] = useState<"table" | "template" | "dataSource">(
"table"
);
// config가 없으면 기본값 사용
const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
// config 업데이트 헬퍼
const updateConfig = (partial: Partial<PopCardListConfig>) => {
onUpdate({ ...cfg, ...partial });
};
// 테이블이 선택되었는지 확인
const hasTable = !!cfg.dataSource?.tableName;
return (
<div className="flex h-full flex-col">
{/* 탭 헤더 - 3탭 구조 */}
<div className="flex border-b">
<button
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
activeTab === "table"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("table")}
>
</button>
<button
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
activeTab === "template"
? "border-b-2 border-primary text-primary"
: hasTable
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground/50 cursor-not-allowed"
}`}
onClick={() => hasTable && setActiveTab("template")}
disabled={!hasTable}
>
릿
</button>
<button
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
activeTab === "dataSource"
? "border-b-2 border-primary text-primary"
: hasTable
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground/50 cursor-not-allowed"
}`}
onClick={() => hasTable && setActiveTab("dataSource")}
disabled={!hasTable}
>
</button>
</div>
{/* 탭 내용 */}
<div className="flex-1 overflow-y-auto p-3">
{activeTab === "table" && (
<TableSelectTab config={cfg} onUpdate={updateConfig} />
)}
{activeTab === "template" && (
<CardTemplateTab config={cfg} onUpdate={updateConfig} />
)}
{activeTab === "dataSource" && (
<DataSourceTab config={cfg} onUpdate={updateConfig} />
)}
</div>
</div>
);
}
// ===== 테이블 선택 탭 =====
function TableSelectTab({
config,
onUpdate,
}: {
config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void;
}) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const [tables, setTables] = useState<TableInfo[]>([]);
// 테이블 목록 로드
useEffect(() => {
fetchTableList().then(setTables);
}, []);
return (
<div className="space-y-4">
{/* 테이블 선택 */}
<div>
<Label className="text-xs font-medium"> </Label>
<p className="mb-2 mt-1 text-[10px] text-muted-foreground">
</p>
<Select
value={dataSource.tableName || ""}
onValueChange={(val) => {
// 테이블 변경 시 관련 설정 초기화
onUpdate({
dataSource: {
tableName: val,
joins: undefined,
filters: undefined,
sort: undefined,
limit: undefined,
},
cardTemplate: DEFAULT_TEMPLATE,
});
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 선택된 테이블 정보 */}
{dataSource.tableName && (
<div className="rounded-md border bg-muted/30 p-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10">
<Database className="h-4 w-4 text-primary" />
</div>
<div>
<p className="text-xs font-medium">{dataSource.tableName}</p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
</div>
)}
</div>
);
}
// ===== 데이터 소스 탭 =====
function DataSourceTab({
config,
onUpdate,
}: {
config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void;
}) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
// 테이블 목록 로드
useEffect(() => {
fetchTableList().then(setTables);
}, []);
// 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
if (dataSource.tableName) {
fetchTableColumns(dataSource.tableName).then(setColumns);
} else {
setColumns([]);
}
}, [dataSource.tableName]);
const updateDataSource = (partial: Partial<CardListDataSource>) => {
onUpdate({
dataSource: { ...dataSource, ...partial },
});
};
// 테이블이 선택되지 않은 경우
if (!dataSource.tableName) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Database className="mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* 현재 선택된 테이블 표시 */}
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">{dataSource.tableName}</span>
</div>
{/* 조인 설정 */}
<CollapsibleSection
title="조인 설정"
badge={
dataSource.joins && dataSource.joins.length > 0
? `${dataSource.joins.length}`
: "없음"
}
>
<JoinSettingsSection
dataSource={dataSource}
tables={tables}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
{/* 필터 설정 */}
<CollapsibleSection
title="필터 조건"
badge={
dataSource.filters && dataSource.filters.length > 0
? `${dataSource.filters.length}`
: "없음"
}
>
<FilterSettingsSection
dataSource={dataSource}
columns={columns}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
{/* 정렬 설정 */}
<CollapsibleSection
title="정렬 기준"
badge={dataSource.sort ? "설정됨" : "없음"}
>
<SortSettingsSection
dataSource={dataSource}
columns={columns}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
{/* 표시 개수 */}
<CollapsibleSection
title="표시 개수"
badge={
dataSource.limit?.mode === "limited"
? `${dataSource.limit.count}`
: "전체"
}
>
<LimitSettingsSection
dataSource={dataSource}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
</div>
);
}
// ===== 카드 템플릿 탭 =====
function CardTemplateTab({
config,
onUpdate,
}: {
config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void;
}) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const template = config.cardTemplate || DEFAULT_TEMPLATE;
const [columns, setColumns] = useState<ColumnInfo[]>([]);
// 테이블 컬럼 로드
useEffect(() => {
if (dataSource.tableName) {
fetchTableColumns(dataSource.tableName).then(setColumns);
} else {
setColumns([]);
}
}, [dataSource.tableName]);
const updateTemplate = (partial: Partial<CardTemplateConfig>) => {
onUpdate({
cardTemplate: { ...template, ...partial },
});
};
// 테이블 미선택 시 안내
if (!dataSource.tableName) {
return (
<div className="flex h-40 items-center justify-center text-center">
<div className="text-muted-foreground">
<p className="text-sm"> </p>
<p className="mt-1 text-xs"> </p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 헤더 설정 */}
<CollapsibleSection title="헤더 설정" defaultOpen>
<HeaderSettingsSection
header={template.header || DEFAULT_HEADER}
columns={columns}
onUpdate={(header) => updateTemplate({ header })}
/>
</CollapsibleSection>
{/* 이미지 설정 */}
<CollapsibleSection title="이미지 설정" defaultOpen>
<ImageSettingsSection
image={template.image || DEFAULT_IMAGE}
columns={columns}
onUpdate={(image) => updateTemplate({ image })}
/>
</CollapsibleSection>
{/* 본문 필드 */}
<CollapsibleSection
title="본문 필드"
badge={`${template.body?.fields?.length || 0}`}
defaultOpen
>
<BodyFieldsSection
body={template.body || DEFAULT_BODY}
columns={columns}
onUpdate={(body) => updateTemplate({ body })}
/>
</CollapsibleSection>
{/* 레이아웃 설정 */}
<CollapsibleSection title="레이아웃 설정" defaultOpen>
<LayoutSettingsSection config={config} onUpdate={onUpdate} />
</CollapsibleSection>
</div>
);
}
// ===== 접기/펴기 섹션 컴포넌트 =====
function CollapsibleSection({
title,
badge,
defaultOpen = false,
children,
}: {
title: string;
badge?: string;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="rounded-md border">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen(!open)}
>
<div className="flex items-center gap-2">
{open ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-xs font-medium">{title}</span>
</div>
{badge && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{badge}
</span>
)}
</button>
{open && <div className="border-t px-3 py-3">{children}</div>}
</div>
);
}
// ===== 헤더 설정 섹션 =====
function HeaderSettingsSection({
header,
columns,
onUpdate,
}: {
header: CardHeaderConfig;
columns: ColumnInfo[];
onUpdate: (header: CardHeaderConfig) => void;
}) {
return (
<div className="space-y-3">
{/* 코드 필드 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={header.codeField || "__none__"}
onValueChange={(val) =>
onUpdate({ ...header, codeField: val === "__none__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
(: ITEM032)
</p>
</div>
{/* 제목 필드 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={header.titleField || "__none__"}
onValueChange={(val) =>
onUpdate({ ...header, titleField: val === "__none__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
(: 너트 M10)
</p>
</div>
</div>
);
}
// ===== 이미지 설정 섹션 =====
function ImageSettingsSection({
image,
columns,
onUpdate,
}: {
image: CardImageConfig;
columns: ColumnInfo[];
onUpdate: (image: CardImageConfig) => void;
}) {
return (
<div className="space-y-3">
{/* 이미지 사용 여부 */}
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={image.enabled}
onCheckedChange={(checked) => onUpdate({ ...image, enabled: checked })}
/>
</div>
{image.enabled && (
<>
{/* 기본 이미지 미리보기 */}
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-2 text-[10px] text-muted-foreground">
</div>
<div className="flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-md border bg-card">
<img
src={image.defaultImage || DEFAULT_CARD_IMAGE}
alt="기본 이미지"
className="h-12 w-12 object-contain"
/>
</div>
</div>
</div>
{/* 기본 이미지 URL */}
<div>
<Label className="text-[10px]"> URL</Label>
<Input
value={image.defaultImage || ""}
onChange={(e) =>
onUpdate({
...image,
defaultImage: e.target.value || DEFAULT_CARD_IMAGE,
})
}
placeholder="이미지 URL 입력"
className="mt-1 h-7 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
{/* 이미지 컬럼 선택 */}
<div>
<Label className="text-[10px]"> URL ()</Label>
<Select
value={image.imageColumn || "__none__"}
onValueChange={(val) =>
onUpdate({ ...image, imageColumn: val === "__none__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> ( )</SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
DB에서 URL을 . URL이
</p>
</div>
</>
)}
</div>
);
}
// ===== 본문 필드 섹션 =====
function BodyFieldsSection({
body,
columns,
onUpdate,
}: {
body: CardBodyConfig;
columns: ColumnInfo[];
onUpdate: (body: CardBodyConfig) => void;
}) {
const fields = body.fields || [];
// 필드 추가
const addField = () => {
const newField: CardFieldBinding = {
id: `field-${Date.now()}`,
columnName: "",
label: "",
textColor: undefined,
};
onUpdate({ fields: [...fields, newField] });
};
// 필드 업데이트
const updateField = (index: number, updated: CardFieldBinding) => {
const newFields = [...fields];
newFields[index] = updated;
onUpdate({ fields: newFields });
};
// 필드 삭제
const deleteField = (index: number) => {
const newFields = fields.filter((_, i) => i !== index);
onUpdate({ fields: newFields });
};
// 필드 순서 이동
const moveField = (index: number, direction: "up" | "down") => {
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= fields.length) return;
const newFields = [...fields];
[newFields[index], newFields[newIndex]] = [
newFields[newIndex],
newFields[index],
];
onUpdate({ fields: newFields });
};
return (
<div className="space-y-3">
{/* 필드 목록 */}
{fields.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{fields.map((field, index) => (
<FieldEditor
key={field.id}
field={field}
index={index}
columns={columns}
totalCount={fields.length}
onUpdate={(updated) => updateField(index, updated)}
onDelete={() => deleteField(index)}
onMove={(dir) => moveField(index, dir)}
/>
))}
</div>
)}
{/* 필드 추가 버튼 */}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addField}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ===== 필드 편집기 =====
function FieldEditor({
field,
index,
columns,
totalCount,
onUpdate,
onDelete,
onMove,
}: {
field: CardFieldBinding;
index: number;
columns: ColumnInfo[];
totalCount: number;
onUpdate: (field: CardFieldBinding) => void;
onDelete: () => void;
onMove: (direction: "up" | "down") => void;
}) {
return (
<div className="rounded-md border bg-card p-2">
<div className="flex items-start gap-2">
{/* 순서 이동 버튼 */}
<div className="flex flex-col gap-0.5 pt-1">
<button
type="button"
className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
onClick={() => onMove("up")}
disabled={index === 0}
>
<ChevronDown className="h-3 w-3 rotate-180" />
</button>
<button
type="button"
className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
onClick={() => onMove("down")}
disabled={index === totalCount - 1}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
{/* 필드 설정 */}
<div className="flex-1 space-y-2">
<div className="flex gap-2">
{/* 라벨 */}
<div className="flex-1">
<Label className="text-[10px]"></Label>
<Input
value={field.label}
onChange={(e) => onUpdate({ ...field, label: e.target.value })}
placeholder="예: 발주일"
className="mt-1 h-7 text-xs"
/>
</div>
{/* 컬럼 */}
<div className="flex-1">
<Label className="text-[10px]"></Label>
<Select
value={field.columnName || "__placeholder__"}
onValueChange={(val) =>
onUpdate({ ...field, columnName: val === "__placeholder__" ? "" : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__placeholder__" disabled>
</SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 텍스트 색상 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={field.textColor || "__default__"}
onValueChange={(val) =>
onUpdate({ ...field, textColor: val === "__default__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="기본" />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex items-center gap-2">
{opt.value !== "__default__" && (
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: opt.value }}
/>
)}
{opt.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
}
// ===== 레이아웃 설정 섹션 =====
function LayoutSettingsSection({
config,
onUpdate,
}: {
config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void;
}) {
const isGridMode = config.layoutMode === "grid";
return (
<div className="space-y-3">
{/* 카드 크기 */}
<div>
<Label className="text-[10px]"> </Label>
<div className="mt-1.5 flex gap-2">
{(["small", "medium", "large"] as CardSize[]).map((size) => (
<button
key={size}
type="button"
className={`flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors ${
config.cardSize === size
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background hover:bg-accent"
}`}
onClick={() => onUpdate({ cardSize: size })}
>
{CARD_SIZE_LABELS[size]}
</button>
))}
</div>
</div>
{/* 배치 방식 */}
<div>
<Label className="text-[10px]"> </Label>
<div className="mt-1.5 space-y-1.5">
{(["grid", "horizontal", "vertical"] as CardLayoutMode[]).map(
(mode) => (
<button
key={mode}
type="button"
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-xs transition-colors ${
config.layoutMode === mode
? "border-primary bg-primary/10 text-primary"
: "border-input bg-background hover:bg-accent"
}`}
onClick={() => onUpdate({ layoutMode: mode })}
>
<div
className={`h-3 w-3 rounded-full border-2 ${
config.layoutMode === mode
? "border-primary bg-primary"
: "border-muted-foreground"
}`}
/>
{CARD_LAYOUT_MODE_LABELS[mode]}
</button>
)
)}
</div>
</div>
{/* 격자 배치일 때만 한 줄 카드 수 표시 */}
{isGridMode && (
<div>
<Label className="text-[10px]"> </Label>
<Select
value={String(config.cardsPerRow || 3)}
onValueChange={(val) =>
onUpdate({ cardsPerRow: parseInt(val, 10) })
}
>
<SelectTrigger className="mt-1.5 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6].map((num) => (
<SelectItem key={num} value={String(num)}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
);
}
// ===== 조인 설정 섹션 =====
function JoinSettingsSection({
dataSource,
tables,
onUpdate,
}: {
dataSource: CardListDataSource;
tables: TableInfo[];
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const joins = dataSource.joins || [];
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [targetColumnsMap, setTargetColumnsMap] = useState<
Record<string, ColumnInfo[]>
>({});
// 소스 테이블 컬럼 로드
useEffect(() => {
if (dataSource.tableName) {
fetchTableColumns(dataSource.tableName).then(setSourceColumns);
}
}, [dataSource.tableName]);
// 조인 추가
const addJoin = () => {
const newJoin: CardColumnJoin = {
targetTable: "",
joinType: "LEFT",
sourceColumn: "",
targetColumn: "",
};
onUpdate({ joins: [...joins, newJoin] });
};
// 조인 업데이트
const updateJoin = (index: number, updated: CardColumnJoin) => {
const newJoins = [...joins];
newJoins[index] = updated;
onUpdate({ joins: newJoins });
// 대상 테이블 컬럼 로드
if (updated.targetTable && !targetColumnsMap[updated.targetTable]) {
fetchTableColumns(updated.targetTable).then((cols) => {
setTargetColumnsMap((prev) => ({
...prev,
[updated.targetTable]: cols,
}));
});
}
};
// 조인 삭제
const deleteJoin = (index: number) => {
const newJoins = joins.filter((_, i) => i !== index);
onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined });
};
return (
<div className="space-y-3">
{joins.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{joins.map((join, index) => (
<div key={index} className="rounded-md border bg-card p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={() => deleteJoin(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 조인 타입 */}
<Select
value={join.joinType}
onValueChange={(val) =>
updateJoin(index, {
...join,
joinType: val as "LEFT" | "INNER" | "RIGHT",
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEFT">LEFT JOIN</SelectItem>
<SelectItem value="INNER">INNER JOIN</SelectItem>
<SelectItem value="RIGHT">RIGHT JOIN</SelectItem>
</SelectContent>
</Select>
{/* 대상 테이블 */}
<Select
value={join.targetTable || ""}
onValueChange={(val) =>
updateJoin(index, {
...join,
targetTable: val,
targetColumn: "",
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="대상 테이블" />
</SelectTrigger>
<SelectContent>
{tables
.filter((t) => t.tableName !== dataSource.tableName)
.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
{/* ON 조건 */}
{join.targetTable && (
<div className="flex items-center gap-1 rounded bg-muted/50 p-1.5">
<Select
value={join.sourceColumn || ""}
onValueChange={(val) =>
updateJoin(index, { ...join, sourceColumn: val })
}
>
<SelectTrigger className="h-6 text-[10px] flex-1">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
{sourceColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">=</span>
<Select
value={join.targetColumn || ""}
onValueChange={(val) =>
updateJoin(index, { ...join, targetColumn: val })
}
>
<SelectTrigger className="h-6 text-[10px] flex-1">
<SelectValue placeholder="대상 컬럼" />
</SelectTrigger>
<SelectContent>
{(targetColumnsMap[join.targetTable] || []).map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addJoin}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ===== 필터 설정 섹션 =====
function FilterSettingsSection({
dataSource,
columns,
onUpdate,
}: {
dataSource: CardListDataSource;
columns: ColumnInfo[];
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const filters = dataSource.filters || [];
const operators: { value: FilterOperator; label: string }[] = [
{ value: "=", label: "=" },
{ value: "!=", label: "!=" },
{ value: ">", label: ">" },
{ value: ">=", label: ">=" },
{ value: "<", label: "<" },
{ value: "<=", label: "<=" },
{ value: "like", label: "LIKE" },
];
// 필터 추가
const addFilter = () => {
const newFilter: CardColumnFilter = {
column: "",
operator: "=",
value: "",
};
onUpdate({ filters: [...filters, newFilter] });
};
// 필터 업데이트
const updateFilter = (index: number, updated: CardColumnFilter) => {
const newFilters = [...filters];
newFilters[index] = updated;
onUpdate({ filters: newFilters });
};
// 필터 삭제
const deleteFilter = (index: number) => {
const newFilters = filters.filter((_, i) => i !== index);
onUpdate({ filters: newFilters.length > 0 ? newFilters : undefined });
};
return (
<div className="space-y-3">
{filters.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{filters.map((filter, index) => (
<div
key={index}
className="flex items-center gap-1 rounded-md border bg-card p-1.5"
>
<Select
value={filter.column || ""}
onValueChange={(val) =>
updateFilter(index, { ...filter, column: val })
}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) =>
updateFilter(index, { ...filter, value: e.target.value })
}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addFilter}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ===== 정렬 설정 섹션 =====
function SortSettingsSection({
dataSource,
columns,
onUpdate,
}: {
dataSource: CardListDataSource;
columns: ColumnInfo[];
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const sort = dataSource.sort;
return (
<div className="space-y-3">
{/* 정렬 사용 여부 */}
<div className="flex gap-2">
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
!sort ? "border-primary bg-primary/10" : "border-input"
}`}
onClick={() => onUpdate({ sort: undefined })}
>
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
sort ? "border-primary bg-primary/10" : "border-input"
}`}
onClick={() =>
onUpdate({ sort: { column: "", direction: "asc" } })
}
>
</button>
</div>
{sort && (
<div className="space-y-2">
{/* 정렬 컬럼 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={sort.column || ""}
onValueChange={(val) =>
onUpdate({ sort: { ...sort, column: val } })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 정렬 방향 */}
<div>
<Label className="text-[10px]"> </Label>
<div className="mt-1 flex gap-2">
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
sort.direction === "asc"
? "border-primary bg-primary/10"
: "border-input"
}`}
onClick={() => onUpdate({ sort: { ...sort, direction: "asc" } })}
>
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
sort.direction === "desc"
? "border-primary bg-primary/10"
: "border-input"
}`}
onClick={() =>
onUpdate({ sort: { ...sort, direction: "desc" } })
}
>
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ===== 표시 개수 설정 섹션 =====
function LimitSettingsSection({
dataSource,
onUpdate,
}: {
dataSource: CardListDataSource;
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const limit = dataSource.limit || { mode: "all" as const };
const isLimited = limit.mode === "limited";
return (
<div className="space-y-3">
{/* 모드 선택 */}
<div className="flex gap-2">
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
!isLimited ? "border-primary bg-primary/10" : "border-input"
}`}
onClick={() => onUpdate({ limit: { mode: "all" } })}
>
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
isLimited ? "border-primary bg-primary/10" : "border-input"
}`}
onClick={() => onUpdate({ limit: { mode: "limited", count: 10 } })}
>
</button>
</div>
{isLimited && (
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
value={limit.count || 10}
onChange={(e) =>
onUpdate({
limit: {
mode: "limited",
count: parseInt(e.target.value, 10) || 10,
},
})
}
className="mt-1 h-7 text-xs"
/>
</div>
)}
</div>
);
}