1440 lines
42 KiB
TypeScript
1440 lines
42 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|