2026-02-12 11:07:58 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-24 15:54:57 +09:00
|
|
|
* pop-card-list 설정 패널
|
2026-02-12 11:07:58 +09:00
|
|
|
*
|
2026-02-25 17:03:47 +09:00
|
|
|
* 2개 탭:
|
|
|
|
|
* [기본 설정] - 테이블 선택 + 조인/정렬 + 레이아웃 설정
|
|
|
|
|
* [카드 템플릿] - 헤더/이미지/본문/입력/계산/담기 설정
|
2026-02-12 11:07:58 +09:00
|
|
|
*/
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
import React, { useState, useEffect, useMemo } from "react";
|
2026-02-25 17:03:47 +09:00
|
|
|
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react";
|
2026-02-24 15:54:57 +09:00
|
|
|
import type { GridMode } from "@/components/pop/designer/types/pop-layout";
|
|
|
|
|
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
|
2026-02-12 11:07:58 +09:00
|
|
|
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,
|
2026-02-25 17:03:47 +09:00
|
|
|
SelectGroup,
|
2026-02-12 11:07:58 +09:00
|
|
|
SelectItem,
|
2026-02-25 17:03:47 +09:00
|
|
|
SelectLabel,
|
2026-02-12 11:07:58 +09:00
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
2026-02-25 17:03:47 +09:00
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-02-12 11:07:58 +09:00
|
|
|
import type {
|
|
|
|
|
PopCardListConfig,
|
|
|
|
|
CardListDataSource,
|
2026-02-25 17:03:47 +09:00
|
|
|
CardSortConfig,
|
2026-02-12 11:07:58 +09:00
|
|
|
CardTemplateConfig,
|
|
|
|
|
CardHeaderConfig,
|
|
|
|
|
CardImageConfig,
|
|
|
|
|
CardBodyConfig,
|
|
|
|
|
CardFieldBinding,
|
2026-02-25 17:03:47 +09:00
|
|
|
FieldValueType,
|
|
|
|
|
FormulaOperator,
|
|
|
|
|
FormulaRightType,
|
2026-02-12 11:07:58 +09:00
|
|
|
CardColumnJoin,
|
|
|
|
|
CardColumnFilter,
|
2026-02-24 15:54:57 +09:00
|
|
|
CardScrollDirection,
|
2026-02-12 11:07:58 +09:00
|
|
|
FilterOperator,
|
2026-02-24 15:54:57 +09:00
|
|
|
CardInputFieldConfig,
|
2026-02-25 17:03:47 +09:00
|
|
|
CardPackageConfig,
|
2026-02-24 15:54:57 +09:00
|
|
|
CardCartActionConfig,
|
2026-02-25 17:03:47 +09:00
|
|
|
CardResponsiveConfig,
|
|
|
|
|
ResponsiveDisplayMode,
|
2026-02-12 11:07:58 +09:00
|
|
|
} from "../types";
|
|
|
|
|
import {
|
2026-02-24 15:54:57 +09:00
|
|
|
CARD_SCROLL_DIRECTION_LABELS,
|
2026-02-25 17:03:47 +09:00
|
|
|
RESPONSIVE_DISPLAY_LABELS,
|
2026-02-12 11:07:58 +09:00
|
|
|
DEFAULT_CARD_IMAGE,
|
|
|
|
|
} from "../types";
|
|
|
|
|
import {
|
|
|
|
|
fetchTableList,
|
|
|
|
|
fetchTableColumns,
|
|
|
|
|
type TableInfo,
|
|
|
|
|
type ColumnInfo,
|
|
|
|
|
} from "../pop-dashboard/utils/dataFetcher";
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// ===== 테이블별 그룹화된 컬럼 =====
|
|
|
|
|
|
|
|
|
|
interface ColumnGroup {
|
|
|
|
|
tableName: string;
|
|
|
|
|
displayName: string;
|
|
|
|
|
columns: ColumnInfo[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 11:07:58 +09:00
|
|
|
// ===== Props =====
|
|
|
|
|
|
|
|
|
|
interface ConfigPanelProps {
|
|
|
|
|
config: PopCardListConfig | undefined;
|
|
|
|
|
onUpdate: (config: PopCardListConfig) => void;
|
2026-02-24 15:54:57 +09:00
|
|
|
currentMode?: GridMode;
|
|
|
|
|
currentColSpan?: number;
|
2026-02-12 11:07:58 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 기본값 =====
|
|
|
|
|
|
|
|
|
|
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,
|
2026-02-24 15:54:57 +09:00
|
|
|
scrollDirection: "vertical",
|
|
|
|
|
gridColumns: 2,
|
|
|
|
|
gridRows: 3,
|
|
|
|
|
cardSize: "large",
|
2026-02-12 11:07:58 +09:00
|
|
|
};
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// ===== 색상 옵션 (본문 필드 텍스트 색상) =====
|
2026-02-12 11:07:58 +09:00
|
|
|
|
|
|
|
|
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: "회색" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ===== 메인 컴포넌트 =====
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) {
|
2026-02-25 17:03:47 +09:00
|
|
|
const [activeTab, setActiveTab] = useState<"basic" | "template">("basic");
|
2026-02-12 11:07:58 +09:00
|
|
|
|
|
|
|
|
const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
|
|
|
|
|
|
|
|
|
|
const updateConfig = (partial: Partial<PopCardListConfig>) => {
|
|
|
|
|
onUpdate({ ...cfg, ...partial });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hasTable = !!cfg.dataSource?.tableName;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full flex-col">
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 탭 헤더 - 2탭 */}
|
2026-02-12 11:07:58 +09:00
|
|
|
<div className="flex border-b">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
|
2026-02-24 15:54:57 +09:00
|
|
|
activeTab === "basic"
|
2026-02-12 11:07:58 +09:00
|
|
|
? "border-b-2 border-primary text-primary"
|
|
|
|
|
: "text-muted-foreground hover:text-foreground"
|
|
|
|
|
}`}
|
2026-02-24 15:54:57 +09:00
|
|
|
onClick={() => setActiveTab("basic")}
|
2026-02-12 11:07:58 +09:00
|
|
|
>
|
2026-02-24 15:54:57 +09:00
|
|
|
기본 설정
|
2026-02-12 11:07:58 +09:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
|
2026-02-24 15:54:57 +09:00
|
|
|
activeTab === "template"
|
2026-02-12 11:07:58 +09:00
|
|
|
? "border-b-2 border-primary text-primary"
|
|
|
|
|
: hasTable
|
|
|
|
|
? "text-muted-foreground hover:text-foreground"
|
|
|
|
|
: "text-muted-foreground/50 cursor-not-allowed"
|
|
|
|
|
}`}
|
2026-02-24 15:54:57 +09:00
|
|
|
onClick={() => hasTable && setActiveTab("template")}
|
2026-02-12 11:07:58 +09:00
|
|
|
disabled={!hasTable}
|
|
|
|
|
>
|
2026-02-24 15:54:57 +09:00
|
|
|
카드 템플릿
|
2026-02-12 11:07:58 +09:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 탭 내용 */}
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-3">
|
2026-02-24 15:54:57 +09:00
|
|
|
{activeTab === "basic" && (
|
|
|
|
|
<BasicSettingsTab
|
|
|
|
|
config={cfg}
|
|
|
|
|
onUpdate={updateConfig}
|
|
|
|
|
currentMode={currentMode}
|
|
|
|
|
currentColSpan={currentColSpan}
|
|
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
)}
|
2026-02-24 15:54:57 +09:00
|
|
|
{activeTab === "template" && (
|
|
|
|
|
<CardTemplateTab config={cfg} onUpdate={updateConfig} />
|
|
|
|
|
)}
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
function BasicSettingsTab({
|
2026-02-12 11:07:58 +09:00
|
|
|
config,
|
|
|
|
|
onUpdate,
|
2026-02-24 15:54:57 +09:00
|
|
|
currentMode,
|
|
|
|
|
currentColSpan,
|
2026-02-12 11:07:58 +09:00
|
|
|
}: {
|
|
|
|
|
config: PopCardListConfig;
|
|
|
|
|
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
2026-02-24 15:54:57 +09:00
|
|
|
currentMode?: GridMode;
|
|
|
|
|
currentColSpan?: number;
|
2026-02-12 11:07:58 +09:00
|
|
|
}) {
|
|
|
|
|
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
|
|
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
2026-02-25 17:03:47 +09:00
|
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
|
|
|
const [joinColumnsMap, setJoinColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
|
2026-02-12 11:07:58 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchTableList().then(setTables);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (dataSource.tableName) {
|
|
|
|
|
fetchTableColumns(dataSource.tableName).then(setColumns);
|
|
|
|
|
} else {
|
|
|
|
|
setColumns([]);
|
|
|
|
|
}
|
|
|
|
|
}, [dataSource.tableName]);
|
|
|
|
|
|
|
|
|
|
// 조인 테이블 컬럼 로드
|
|
|
|
|
const joinsKey = useMemo(
|
|
|
|
|
() => JSON.stringify((dataSource.joins || []).map((j) => j.targetTable)),
|
|
|
|
|
[dataSource.joins]
|
|
|
|
|
);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const joins = dataSource.joins || [];
|
|
|
|
|
const targetTables = joins
|
|
|
|
|
.map((j) => j.targetTable)
|
|
|
|
|
.filter((t): t is string => !!t);
|
|
|
|
|
if (targetTables.length === 0) {
|
|
|
|
|
setJoinColumnsMap({});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
Promise.all(
|
|
|
|
|
targetTables.map(async (table) => {
|
|
|
|
|
const cols = await fetchTableColumns(table);
|
|
|
|
|
return { table, cols };
|
|
|
|
|
})
|
|
|
|
|
).then((results) => {
|
|
|
|
|
const map: Record<string, ColumnInfo[]> = {};
|
|
|
|
|
results.forEach(({ table, cols }) => { map[table] = cols; });
|
|
|
|
|
setJoinColumnsMap(map);
|
|
|
|
|
});
|
|
|
|
|
}, [joinsKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
const getTableDisplayName = (tableName: string) => {
|
|
|
|
|
const found = tables.find((t) => t.tableName === tableName);
|
|
|
|
|
return found?.displayName || tableName;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const columnGroups: ColumnGroup[] = useMemo(() => {
|
|
|
|
|
const groups: ColumnGroup[] = [];
|
|
|
|
|
if (dataSource.tableName && columns.length > 0) {
|
|
|
|
|
groups.push({
|
|
|
|
|
tableName: dataSource.tableName,
|
|
|
|
|
displayName: getTableDisplayName(dataSource.tableName),
|
|
|
|
|
columns,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
(dataSource.joins || []).forEach((join) => {
|
|
|
|
|
if (join.targetTable && joinColumnsMap[join.targetTable]) {
|
|
|
|
|
groups.push({
|
|
|
|
|
tableName: join.targetTable,
|
|
|
|
|
displayName: getTableDisplayName(join.targetTable),
|
|
|
|
|
columns: joinColumnsMap[join.targetTable],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return groups;
|
|
|
|
|
}, [dataSource.tableName, columns, dataSource.joins, joinColumnsMap, tables]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
const recommendation = useMemo(() => {
|
|
|
|
|
if (!currentMode) return null;
|
2026-02-25 17:03:47 +09:00
|
|
|
const cols = GRID_BREAKPOINTS[currentMode].columns;
|
|
|
|
|
if (cols >= 8) return { rows: 3, cols: 2 };
|
|
|
|
|
if (cols >= 6) return { rows: 3, cols: 1 };
|
2026-02-24 15:54:57 +09:00
|
|
|
return { rows: 2, cols: 1 };
|
|
|
|
|
}, [currentMode]);
|
|
|
|
|
|
|
|
|
|
const maxColumns = useMemo(() => {
|
|
|
|
|
if (!currentColSpan) return 2;
|
|
|
|
|
return currentColSpan >= 8 ? 2 : 1;
|
|
|
|
|
}, [currentColSpan]);
|
|
|
|
|
|
|
|
|
|
const modeLabel = currentMode ? GRID_BREAKPOINTS[currentMode].label : null;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!recommendation) return;
|
|
|
|
|
const currentRows = config.gridRows || 3;
|
|
|
|
|
const currentCols = config.gridColumns || 2;
|
|
|
|
|
if (currentRows !== recommendation.rows || currentCols !== recommendation.cols) {
|
|
|
|
|
onUpdate({
|
|
|
|
|
gridRows: recommendation.rows,
|
|
|
|
|
gridColumns: recommendation.cols,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, [currentMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
const updateDataSource = (partial: Partial<CardListDataSource>) => {
|
|
|
|
|
onUpdate({ dataSource: { ...dataSource, ...partial } });
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-12 11:07:58 +09:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 테이블 선택 */}
|
2026-02-24 15:54:57 +09:00
|
|
|
<CollapsibleSection title="테이블 선택" defaultOpen>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] text-muted-foreground">데이터 테이블</Label>
|
2026-02-25 17:03:47 +09:00
|
|
|
<TableCombobox
|
|
|
|
|
tables={tables}
|
2026-02-24 15:54:57 +09:00
|
|
|
value={dataSource.tableName || ""}
|
2026-02-25 17:03:47 +09:00
|
|
|
onSelect={(val) => {
|
2026-02-24 15:54:57 +09:00
|
|
|
onUpdate({
|
|
|
|
|
dataSource: {
|
|
|
|
|
tableName: val,
|
|
|
|
|
joins: undefined,
|
|
|
|
|
filters: undefined,
|
|
|
|
|
sort: undefined,
|
|
|
|
|
limit: undefined,
|
|
|
|
|
},
|
|
|
|
|
cardTemplate: DEFAULT_TEMPLATE,
|
|
|
|
|
});
|
|
|
|
|
}}
|
2026-02-25 17:03:47 +09:00
|
|
|
/>
|
2026-02-24 15:54:57 +09:00
|
|
|
</div>
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
{dataSource.tableName && (
|
|
|
|
|
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
2026-02-12 11:07:58 +09:00
|
|
|
<Database className="h-4 w-4 text-primary" />
|
2026-02-24 15:54:57 +09:00
|
|
|
<span className="text-xs font-medium">{dataSource.tableName}</span>
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
2026-02-24 15:54:57 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 조인 설정 (테이블 선택 시만 표시) */}
|
|
|
|
|
{dataSource.tableName && (
|
|
|
|
|
<CollapsibleSection
|
|
|
|
|
title="조인 설정"
|
|
|
|
|
badge={
|
|
|
|
|
dataSource.joins && dataSource.joins.length > 0
|
|
|
|
|
? `${dataSource.joins.length}개`
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<JoinSettingsSection
|
|
|
|
|
dataSource={dataSource}
|
|
|
|
|
tables={tables}
|
|
|
|
|
onUpdate={updateDataSource}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 정렬 기준 (테이블 선택 시만 표시) */}
|
|
|
|
|
{dataSource.tableName && (
|
|
|
|
|
<CollapsibleSection
|
|
|
|
|
title="정렬 기준"
|
|
|
|
|
badge={
|
|
|
|
|
dataSource.sort
|
|
|
|
|
? Array.isArray(dataSource.sort)
|
|
|
|
|
? dataSource.sort.length > 0 ? `${dataSource.sort.length}개` : undefined
|
|
|
|
|
: "1개"
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SortSettingsSection
|
|
|
|
|
dataSource={dataSource}
|
|
|
|
|
columnGroups={columnGroups}
|
|
|
|
|
onUpdate={updateDataSource}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 레이아웃 설정 */}
|
2026-02-24 15:54:57 +09:00
|
|
|
<CollapsibleSection title="레이아웃 설정" defaultOpen>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{modeLabel && (
|
|
|
|
|
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5">
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">현재:</span>
|
|
|
|
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
|
|
|
|
{modeLabel}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">스크롤 방향</Label>
|
|
|
|
|
<div className="mt-1.5 space-y-1.5">
|
|
|
|
|
{(["horizontal", "vertical"] as CardScrollDirection[]).map((dir) => (
|
|
|
|
|
<button
|
|
|
|
|
key={dir}
|
|
|
|
|
type="button"
|
|
|
|
|
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-xs transition-colors ${
|
|
|
|
|
config.scrollDirection === dir
|
|
|
|
|
? "border-primary bg-primary/10 text-primary"
|
|
|
|
|
: "border-input bg-background hover:bg-accent"
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => onUpdate({ scrollDirection: dir })}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`h-3 w-3 rounded-full border-2 ${
|
|
|
|
|
config.scrollDirection === dir
|
|
|
|
|
? "border-primary bg-primary"
|
|
|
|
|
: "border-muted-foreground"
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
{CARD_SCROLL_DIRECTION_LABELS[dir]}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">카드 배치 (행 x 열)</Label>
|
|
|
|
|
<div className="mt-1.5 flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
max={10}
|
|
|
|
|
value={config.gridRows || 3}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdate({ gridRows: parseInt(e.target.value, 10) || 3 })
|
|
|
|
|
}
|
|
|
|
|
className="h-7 w-16 text-center text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-muted-foreground">x</span>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
max={maxColumns}
|
|
|
|
|
value={Math.min(config.gridColumns || 2, maxColumns)}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
onUpdate({ gridColumns: Math.min(parseInt(e.target.value, 10) || 1, maxColumns) })
|
|
|
|
|
}
|
|
|
|
|
className="h-7 w-16 text-center text-xs"
|
|
|
|
|
disabled={maxColumns === 1}
|
|
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
2026-02-24 15:54:57 +09:00
|
|
|
|
|
|
|
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
|
|
|
|
{config.scrollDirection === "horizontal"
|
|
|
|
|
? "격자로 배치, 가로 스크롤"
|
|
|
|
|
: "격자로 배치, 세로 스크롤"}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mt-0.5 text-[9px] text-muted-foreground">
|
|
|
|
|
{maxColumns === 1
|
|
|
|
|
? "현재 모드에서 열 최대 1 (모드 변경 시 자동 적용)"
|
|
|
|
|
: "모드 변경 시 열/행 자동 적용 / 열 최대 2"}
|
|
|
|
|
</p>
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-24 15:54:57 +09:00
|
|
|
</CollapsibleSection>
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// (DataSourceTab 제거됨 - 조인/정렬 설정이 BasicSettingsTab으로 통합)
|
|
|
|
|
|
|
|
|
|
// ===== 카드 템플릿 탭 =====
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
function CardTemplateTab({
|
2026-02-12 11:07:58 +09:00
|
|
|
config,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
config: PopCardListConfig;
|
|
|
|
|
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
2026-02-25 17:03:47 +09:00
|
|
|
const template = config.cardTemplate || DEFAULT_TEMPLATE;
|
2026-02-12 11:07:58 +09:00
|
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
2026-02-25 17:03:47 +09:00
|
|
|
const [mainColumns, setMainColumns] = useState<ColumnInfo[]>([]);
|
|
|
|
|
const [joinColumnsMap, setJoinColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// 테이블 목록 로드 (한글명 표시용)
|
2026-02-12 11:07:58 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
fetchTableList().then(setTables);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// 메인 테이블 컬럼 로드
|
2026-02-12 11:07:58 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (dataSource.tableName) {
|
2026-02-25 17:03:47 +09:00
|
|
|
fetchTableColumns(dataSource.tableName).then(setMainColumns);
|
2026-02-12 11:07:58 +09:00
|
|
|
} else {
|
2026-02-25 17:03:47 +09:00
|
|
|
setMainColumns([]);
|
2026-02-12 11:07:58 +09:00
|
|
|
}
|
|
|
|
|
}, [dataSource.tableName]);
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// 조인 테이블 컬럼 로드
|
|
|
|
|
const joinsKey = useMemo(
|
|
|
|
|
() => JSON.stringify((dataSource.joins || []).map((j) => j.targetTable)),
|
|
|
|
|
[dataSource.joins]
|
|
|
|
|
);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const joins = dataSource.joins || [];
|
|
|
|
|
const targetTables = joins
|
|
|
|
|
.map((j) => j.targetTable)
|
|
|
|
|
.filter((t): t is string => !!t);
|
|
|
|
|
|
|
|
|
|
if (targetTables.length === 0) {
|
|
|
|
|
setJoinColumnsMap({});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
Promise.all(
|
|
|
|
|
targetTables.map(async (table) => {
|
|
|
|
|
const cols = await fetchTableColumns(table);
|
|
|
|
|
return { table, cols };
|
|
|
|
|
})
|
|
|
|
|
).then((results) => {
|
|
|
|
|
const map: Record<string, ColumnInfo[]> = {};
|
|
|
|
|
results.forEach(({ table, cols }) => {
|
|
|
|
|
map[table] = cols;
|
|
|
|
|
});
|
|
|
|
|
setJoinColumnsMap(map);
|
|
|
|
|
});
|
|
|
|
|
}, [joinsKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
const getTableDisplayName = (tableName: string) => {
|
|
|
|
|
const found = tables.find((t) => t.tableName === tableName);
|
|
|
|
|
return found?.displayName || tableName;
|
|
|
|
|
};
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// 테이블별 그룹화된 컬럼 목록
|
|
|
|
|
const columnGroups: ColumnGroup[] = useMemo(() => {
|
|
|
|
|
const groups: ColumnGroup[] = [];
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
if (dataSource.tableName && mainColumns.length > 0) {
|
|
|
|
|
groups.push({
|
|
|
|
|
tableName: dataSource.tableName,
|
|
|
|
|
displayName: getTableDisplayName(dataSource.tableName),
|
|
|
|
|
columns: mainColumns,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
const joins = dataSource.joins || [];
|
|
|
|
|
joins.forEach((join) => {
|
|
|
|
|
if (join.targetTable && joinColumnsMap[join.targetTable]) {
|
|
|
|
|
groups.push({
|
|
|
|
|
tableName: join.targetTable,
|
|
|
|
|
displayName: getTableDisplayName(join.targetTable),
|
|
|
|
|
columns: joinColumnsMap[join.targetTable],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
return groups;
|
|
|
|
|
}, [dataSource.tableName, mainColumns, dataSource.joins, joinColumnsMap, tables]); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// 하위 호환: 단일 배열 (조인 없는 섹션용)
|
|
|
|
|
const columns = mainColumns;
|
2026-02-12 11:07:58 +09:00
|
|
|
|
|
|
|
|
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>
|
2026-02-25 17:03:47 +09:00
|
|
|
<p className="mt-1 text-xs">기본 설정 탭에서 테이블을 선택하세요</p>
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* 헤더 설정 */}
|
|
|
|
|
<CollapsibleSection title="헤더 설정" defaultOpen>
|
|
|
|
|
<HeaderSettingsSection
|
|
|
|
|
header={template.header || DEFAULT_HEADER}
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups={columnGroups}
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate={(header) => updateTemplate({ header })}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
|
|
|
|
{/* 이미지 설정 */}
|
|
|
|
|
<CollapsibleSection title="이미지 설정" defaultOpen>
|
|
|
|
|
<ImageSettingsSection
|
|
|
|
|
image={template.image || DEFAULT_IMAGE}
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups={columnGroups}
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate={(image) => updateTemplate({ image })}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
|
|
|
|
{/* 본문 필드 */}
|
|
|
|
|
<CollapsibleSection
|
|
|
|
|
title="본문 필드"
|
|
|
|
|
badge={`${template.body?.fields?.length || 0}개`}
|
|
|
|
|
defaultOpen
|
|
|
|
|
>
|
|
|
|
|
<BodyFieldsSection
|
|
|
|
|
body={template.body || DEFAULT_BODY}
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups={columnGroups}
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate={(body) => updateTemplate({ body })}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
{/* 입력 필드 설정 */}
|
|
|
|
|
<CollapsibleSection title="입력 필드" defaultOpen={false}>
|
|
|
|
|
<InputFieldSettingsSection
|
|
|
|
|
inputField={config.inputField}
|
|
|
|
|
columns={columns}
|
2026-02-25 17:03:47 +09:00
|
|
|
tables={tables}
|
2026-02-24 15:54:57 +09:00
|
|
|
onUpdate={(inputField) => onUpdate({ inputField })}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 포장등록 설정 */}
|
|
|
|
|
<CollapsibleSection title="포장등록 (계산기)" defaultOpen={false}>
|
|
|
|
|
<PackageSettingsSection
|
|
|
|
|
packageConfig={config.packageConfig}
|
|
|
|
|
onUpdate={(packageConfig) => onUpdate({ packageConfig })}
|
2026-02-24 15:54:57 +09:00
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
|
|
|
|
{/* 담기 버튼 설정 */}
|
2026-02-26 16:00:07 +09:00
|
|
|
<CollapsibleSection title="담기 버튼" defaultOpen={false}>
|
2026-02-24 15:54:57 +09:00
|
|
|
<CartActionSettingsSection
|
|
|
|
|
cartAction={config.cartAction}
|
|
|
|
|
onUpdate={(cartAction) => onUpdate({ cartAction })}
|
2026-02-26 16:00:07 +09:00
|
|
|
cardTemplate={template}
|
|
|
|
|
tableName={dataSource.tableName}
|
2026-02-24 15:54:57 +09:00
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
</CollapsibleSection>
|
2026-02-25 17:03:47 +09:00
|
|
|
|
|
|
|
|
{/* 반응형 표시 설정 */}
|
|
|
|
|
<CollapsibleSection title="반응형 표시" defaultOpen={false}>
|
|
|
|
|
<ResponsiveDisplaySection
|
|
|
|
|
config={config}
|
|
|
|
|
onUpdate={onUpdate}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// ===== 테이블 검색 Combobox =====
|
|
|
|
|
|
|
|
|
|
function TableCombobox({
|
|
|
|
|
tables,
|
|
|
|
|
value,
|
|
|
|
|
onSelect,
|
|
|
|
|
}: {
|
|
|
|
|
tables: TableInfo[];
|
|
|
|
|
value: string;
|
|
|
|
|
onSelect: (tableName: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [search, setSearch] = useState("");
|
|
|
|
|
|
|
|
|
|
const selectedLabel = useMemo(() => {
|
|
|
|
|
const found = tables.find((t) => t.tableName === value);
|
|
|
|
|
return found ? (found.displayName || found.tableName) : "";
|
|
|
|
|
}, [tables, value]);
|
|
|
|
|
|
|
|
|
|
const filtered = useMemo(() => {
|
|
|
|
|
if (!search) return tables;
|
|
|
|
|
const q = search.toLowerCase();
|
|
|
|
|
return tables.filter(
|
|
|
|
|
(t) =>
|
|
|
|
|
t.tableName.toLowerCase().includes(q) ||
|
|
|
|
|
(t.displayName && t.displayName.toLowerCase().includes(q))
|
|
|
|
|
);
|
|
|
|
|
}, [tables, search]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
className="mt-1 h-8 w-full justify-between text-xs"
|
|
|
|
|
>
|
|
|
|
|
{value ? selectedLabel : "테이블을 선택하세요"}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
align="start"
|
|
|
|
|
>
|
|
|
|
|
<Command shouldFilter={false}>
|
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder="테이블명 또는 한글명 검색..."
|
|
|
|
|
className="text-xs"
|
|
|
|
|
value={search}
|
|
|
|
|
onValueChange={setSearch}
|
|
|
|
|
/>
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty className="py-4 text-center text-xs">
|
|
|
|
|
검색 결과가 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{filtered.map((table) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={table.tableName}
|
|
|
|
|
value={table.tableName}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
onSelect(table.tableName);
|
|
|
|
|
setOpen(false);
|
|
|
|
|
setSearch("");
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3.5 w-3.5",
|
|
|
|
|
value === table.tableName ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span>{table.displayName || table.tableName}</span>
|
|
|
|
|
{table.displayName && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">
|
|
|
|
|
{table.tableName}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 테이블별 그룹화된 컬럼 셀렉트 =====
|
|
|
|
|
|
|
|
|
|
function GroupedColumnSelect({
|
|
|
|
|
columnGroups,
|
|
|
|
|
value,
|
|
|
|
|
onValueChange,
|
|
|
|
|
placeholder = "컬럼 선택",
|
|
|
|
|
allowNone = false,
|
|
|
|
|
noneLabel = "선택 안함",
|
|
|
|
|
className,
|
|
|
|
|
}: {
|
|
|
|
|
columnGroups: ColumnGroup[];
|
|
|
|
|
value: string | undefined;
|
|
|
|
|
onValueChange: (value: string | undefined) => void;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
allowNone?: boolean;
|
|
|
|
|
noneLabel?: string;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
value={value || (allowNone ? "__none__" : "__placeholder__")}
|
|
|
|
|
onValueChange={(val) => {
|
|
|
|
|
if (val === "__none__") onValueChange(undefined);
|
|
|
|
|
else if (val === "__placeholder__") return;
|
|
|
|
|
else onValueChange(val);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className={cn("h-7 text-xs", className)}>
|
|
|
|
|
<SelectValue placeholder={placeholder} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{allowNone && (
|
|
|
|
|
<SelectItem value="__none__">{noneLabel}</SelectItem>
|
|
|
|
|
)}
|
|
|
|
|
{!allowNone && (
|
|
|
|
|
<SelectItem value="__placeholder__" disabled>
|
|
|
|
|
{placeholder}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
)}
|
|
|
|
|
{columnGroups.map((group) => (
|
|
|
|
|
<SelectGroup key={group.tableName}>
|
|
|
|
|
<SelectLabel className="text-[10px] font-semibold text-muted-foreground px-2 py-1">
|
|
|
|
|
{group.displayName}
|
|
|
|
|
</SelectLabel>
|
|
|
|
|
{group.columns.map((col) => (
|
|
|
|
|
<SelectItem
|
|
|
|
|
key={`${group.tableName}.${col.name}`}
|
|
|
|
|
value={col.name}
|
|
|
|
|
>
|
|
|
|
|
{col.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectGroup>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 11:07:58 +09:00
|
|
|
// ===== 접기/펴기 섹션 컴포넌트 =====
|
|
|
|
|
|
|
|
|
|
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,
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups,
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
header: CardHeaderConfig;
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups: ColumnGroup[];
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate: (header: CardHeaderConfig) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{/* 코드 필드 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">코드 필드</Label>
|
2026-02-25 17:03:47 +09:00
|
|
|
<GroupedColumnSelect
|
|
|
|
|
columnGroups={columnGroups}
|
|
|
|
|
value={header.codeField}
|
|
|
|
|
onValueChange={(val) => onUpdate({ ...header, codeField: val })}
|
|
|
|
|
placeholder="컬럼 선택 (선택사항)"
|
|
|
|
|
allowNone
|
|
|
|
|
noneLabel="선택 안함"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
|
|
|
카드 헤더 왼쪽에 표시될 코드 (예: ITEM032)
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 제목 필드 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">제목 필드</Label>
|
2026-02-25 17:03:47 +09:00
|
|
|
<GroupedColumnSelect
|
|
|
|
|
columnGroups={columnGroups}
|
|
|
|
|
value={header.titleField}
|
|
|
|
|
onValueChange={(val) => onUpdate({ ...header, titleField: val })}
|
|
|
|
|
placeholder="컬럼 선택 (선택사항)"
|
|
|
|
|
allowNone
|
|
|
|
|
noneLabel="선택 안함"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
|
|
|
카드 헤더 오른쪽에 표시될 제목 (예: 너트 M10)
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 이미지 설정 섹션 =====
|
|
|
|
|
|
|
|
|
|
function ImageSettingsSection({
|
|
|
|
|
image,
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups,
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
image: CardImageConfig;
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups: ColumnGroup[];
|
2026-02-12 11:07:58 +09:00
|
|
|
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>
|
2026-02-25 17:03:47 +09:00
|
|
|
<GroupedColumnSelect
|
|
|
|
|
columnGroups={columnGroups}
|
|
|
|
|
value={image.imageColumn}
|
|
|
|
|
onValueChange={(val) => onUpdate({ ...image, imageColumn: val })}
|
|
|
|
|
placeholder="컬럼 선택 (선택사항)"
|
|
|
|
|
allowNone
|
|
|
|
|
noneLabel="선택 안함 (기본 이미지 사용)"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
|
|
|
DB에서 이미지 URL을 가져올 컬럼. URL이 없으면 기본 이미지 사용
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 본문 필드 섹션 =====
|
|
|
|
|
|
|
|
|
|
function BodyFieldsSection({
|
|
|
|
|
body,
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups,
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
body: CardBodyConfig;
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups: ColumnGroup[];
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate: (body: CardBodyConfig) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const fields = body.fields || [];
|
|
|
|
|
|
|
|
|
|
const addField = () => {
|
|
|
|
|
const newField: CardFieldBinding = {
|
|
|
|
|
id: `field-${Date.now()}`,
|
|
|
|
|
label: "",
|
2026-02-25 17:03:47 +09:00
|
|
|
valueType: "column",
|
|
|
|
|
columnName: "",
|
2026-02-12 11:07:58 +09:00
|
|
|
};
|
|
|
|
|
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}
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups={columnGroups}
|
2026-02-12 11:07:58 +09:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 필드 편집기 =====
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
const VALUE_TYPE_OPTIONS: { value: FieldValueType; label: string }[] = [
|
|
|
|
|
{ value: "column", label: "DB 컬럼" },
|
|
|
|
|
{ value: "formula", label: "계산식" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const FORMULA_OPERATOR_OPTIONS: { value: FormulaOperator; label: string }[] = [
|
|
|
|
|
{ value: "+", label: "+ (더하기)" },
|
|
|
|
|
{ value: "-", label: "- (빼기)" },
|
|
|
|
|
{ value: "*", label: "* (곱하기)" },
|
|
|
|
|
{ value: "/", label: "/ (나누기)" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const FORMULA_RIGHT_TYPE_OPTIONS: { value: FormulaRightType; label: string }[] = [
|
|
|
|
|
{ value: "column", label: "DB 컬럼" },
|
|
|
|
|
{ value: "input", label: "입력값" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-02-12 11:07:58 +09:00
|
|
|
function FieldEditor({
|
|
|
|
|
field,
|
|
|
|
|
index,
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups,
|
2026-02-12 11:07:58 +09:00
|
|
|
totalCount,
|
|
|
|
|
onUpdate,
|
|
|
|
|
onDelete,
|
|
|
|
|
onMove,
|
|
|
|
|
}: {
|
|
|
|
|
field: CardFieldBinding;
|
|
|
|
|
index: number;
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups: ColumnGroup[];
|
2026-02-12 11:07:58 +09:00
|
|
|
totalCount: number;
|
|
|
|
|
onUpdate: (field: CardFieldBinding) => void;
|
|
|
|
|
onDelete: () => void;
|
|
|
|
|
onMove: (direction: "up" | "down") => void;
|
|
|
|
|
}) {
|
2026-02-25 17:03:47 +09:00
|
|
|
const valueType = field.valueType || "column";
|
|
|
|
|
const rightType = field.formulaRightType || "input";
|
|
|
|
|
|
2026-02-12 11:07:58 +09:00
|
|
|
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">
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 1행: 라벨 + 값 유형 */}
|
2026-02-12 11:07:58 +09:00
|
|
|
<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 })}
|
2026-02-25 17:03:47 +09:00
|
|
|
placeholder="예: 미입고"
|
2026-02-12 11:07:58 +09:00
|
|
|
className="mt-1 h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-25 17:03:47 +09:00
|
|
|
<div className="w-24">
|
|
|
|
|
<Label className="text-[10px]">값 유형</Label>
|
2026-02-12 11:07:58 +09:00
|
|
|
<Select
|
2026-02-25 17:03:47 +09:00
|
|
|
value={valueType}
|
|
|
|
|
onValueChange={(val: FieldValueType) => {
|
|
|
|
|
const base = { ...field, valueType: val };
|
|
|
|
|
if (val === "column") {
|
|
|
|
|
base.formulaLeft = undefined;
|
|
|
|
|
base.formulaOperator = undefined;
|
|
|
|
|
base.formulaRightType = undefined;
|
|
|
|
|
base.formulaRight = undefined;
|
|
|
|
|
base.formula = undefined;
|
|
|
|
|
base.unit = undefined;
|
|
|
|
|
} else {
|
|
|
|
|
base.columnName = undefined;
|
|
|
|
|
}
|
|
|
|
|
onUpdate(base);
|
|
|
|
|
}}
|
2026-02-12 11:07:58 +09:00
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
2026-02-25 17:03:47 +09:00
|
|
|
<SelectValue />
|
2026-02-12 11:07:58 +09:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-02-25 17:03:47 +09:00
|
|
|
{VALUE_TYPE_OPTIONS.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
|
|
|
{opt.label}
|
2026-02-12 11:07:58 +09:00
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 2행: 컬럼 선택 또는 수식 빌더 */}
|
|
|
|
|
{valueType === "column" ? (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">컬럼</Label>
|
|
|
|
|
<GroupedColumnSelect
|
|
|
|
|
columnGroups={columnGroups}
|
|
|
|
|
value={field.columnName || undefined}
|
|
|
|
|
onValueChange={(val) => onUpdate({ ...field, columnName: val || "" })}
|
|
|
|
|
placeholder="컬럼 선택"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* 왼쪽 값: DB 컬럼 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">왼쪽 값 (DB 컬럼)</Label>
|
|
|
|
|
<GroupedColumnSelect
|
|
|
|
|
columnGroups={columnGroups}
|
|
|
|
|
value={field.formulaLeft || undefined}
|
|
|
|
|
onValueChange={(val) => onUpdate({ ...field, formulaLeft: val || undefined })}
|
|
|
|
|
placeholder="컬럼 선택"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 연산자 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">연산자</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={field.formulaOperator || "__none__"}
|
|
|
|
|
onValueChange={(val) =>
|
|
|
|
|
onUpdate({ ...field, formulaOperator: val === "__none__" ? undefined : val as FormulaOperator })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
|
|
|
|
<SelectValue placeholder="선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__" disabled>선택</SelectItem>
|
|
|
|
|
{FORMULA_OPERATOR_OPTIONS.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 오른쪽 값 유형 + 값 */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label className="text-[10px]">오른쪽 값</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={rightType}
|
|
|
|
|
onValueChange={(val: FormulaRightType) => {
|
|
|
|
|
const base = { ...field, formulaRightType: val };
|
|
|
|
|
if (val === "input") {
|
|
|
|
|
base.formulaRight = undefined;
|
|
|
|
|
}
|
|
|
|
|
onUpdate(base);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{FORMULA_RIGHT_TYPE_OPTIONS.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
{rightType === "column" && (
|
|
|
|
|
<GroupedColumnSelect
|
|
|
|
|
columnGroups={columnGroups}
|
|
|
|
|
value={field.formulaRight || undefined}
|
|
|
|
|
onValueChange={(val) => onUpdate({ ...field, formulaRight: val || undefined })}
|
|
|
|
|
placeholder="컬럼 선택"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{rightType === "input" && (
|
|
|
|
|
<p className="text-[9px] text-muted-foreground">
|
|
|
|
|
카드의 숫자 입력 필드 값이 사용됩니다
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 단위 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">단위</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={field.unit || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ ...field, unit: e.target.value })}
|
|
|
|
|
className="mt-1 h-7 text-xs"
|
|
|
|
|
placeholder="EA"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 수식 미리보기 */}
|
|
|
|
|
{field.formulaLeft && field.formulaOperator && (
|
|
|
|
|
<div className="rounded bg-muted/50 px-2 py-1.5">
|
|
|
|
|
<p className="text-[9px] text-muted-foreground">수식 미리보기</p>
|
|
|
|
|
<p className="font-mono text-xs font-medium">
|
|
|
|
|
{field.formulaLeft} {field.formulaOperator} {rightType === "input" ? "$input" : (field.formulaRight || "?")}
|
|
|
|
|
</p>
|
|
|
|
|
</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__" && (
|
2026-02-12 11:07:58 +09:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
// ===== 입력 필드 설정 섹션 =====
|
|
|
|
|
|
|
|
|
|
function InputFieldSettingsSection({
|
|
|
|
|
inputField,
|
|
|
|
|
columns,
|
2026-02-25 17:03:47 +09:00
|
|
|
tables,
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
2026-02-24 15:54:57 +09:00
|
|
|
inputField?: CardInputFieldConfig;
|
|
|
|
|
columns: ColumnInfo[];
|
2026-02-25 17:03:47 +09:00
|
|
|
tables: TableInfo[];
|
2026-02-24 15:54:57 +09:00
|
|
|
onUpdate: (inputField: CardInputFieldConfig) => void;
|
2026-02-12 11:07:58 +09:00
|
|
|
}) {
|
2026-02-24 15:54:57 +09:00
|
|
|
const field = inputField || {
|
|
|
|
|
enabled: false,
|
|
|
|
|
unit: "EA",
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// 하위 호환: maxColumn -> limitColumn 마이그레이션
|
|
|
|
|
const effectiveLimitColumn = field.limitColumn || field.maxColumn;
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
const updateField = (partial: Partial<CardInputFieldConfig>) => {
|
|
|
|
|
onUpdate({ ...field, ...partial });
|
|
|
|
|
};
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// 저장 테이블 컬럼 로드
|
|
|
|
|
const [saveTableColumns, setSaveTableColumns] = useState<ColumnInfo[]>([]);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (field.saveTable) {
|
|
|
|
|
fetchTableColumns(field.saveTable).then(setSaveTableColumns);
|
|
|
|
|
} else {
|
|
|
|
|
setSaveTableColumns([]);
|
|
|
|
|
}
|
|
|
|
|
}, [field.saveTable]);
|
|
|
|
|
|
2026-02-12 11:07:58 +09:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
2026-02-24 15:54:57 +09:00
|
|
|
{/* 활성화 스위치 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label className="text-[10px]">입력 필드 사용</Label>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={field.enabled}
|
|
|
|
|
onCheckedChange={(enabled) => updateField({ enabled })}
|
|
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
{field.enabled && (
|
|
|
|
|
<>
|
|
|
|
|
{/* 단위 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] text-muted-foreground">단위</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={field.unit || ""}
|
|
|
|
|
onChange={(e) => updateField({ unit: e.target.value })}
|
|
|
|
|
className="mt-1 h-7 text-xs"
|
|
|
|
|
placeholder="EA"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 제한 기준 컬럼 */}
|
2026-02-24 15:54:57 +09:00
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] text-muted-foreground">
|
2026-02-25 17:03:47 +09:00
|
|
|
제한 기준 컬럼
|
2026-02-24 15:54:57 +09:00
|
|
|
</Label>
|
|
|
|
|
<Select
|
2026-02-25 17:03:47 +09:00
|
|
|
value={effectiveLimitColumn || "__none__"}
|
2026-02-24 15:54:57 +09:00
|
|
|
onValueChange={(val) =>
|
2026-02-25 17:03:47 +09:00
|
|
|
updateField({ limitColumn: val === "__none__" ? undefined : val, maxColumn: undefined })
|
2026-02-24 15:54:57 +09:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
2026-02-25 17:03:47 +09:00
|
|
|
<SelectValue placeholder="컬럼 선택" />
|
2026-02-24 15:54:57 +09:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-02-25 17:03:47 +09:00
|
|
|
<SelectItem value="__none__">제한 없음</SelectItem>
|
2026-02-24 15:54:57 +09:00
|
|
|
{columns.map((col) => (
|
|
|
|
|
<SelectItem key={col.name} value={col.name}>
|
|
|
|
|
{col.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
2026-02-25 17:03:47 +09:00
|
|
|
각 카드 행의 해당 컬럼 값이 숫자패드 최대값 (예: order_qty)
|
2026-02-24 15:54:57 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 저장 대상 테이블 (검색 가능) */}
|
2026-02-24 15:54:57 +09:00
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] text-muted-foreground">
|
2026-02-25 17:03:47 +09:00
|
|
|
저장 대상 테이블
|
2026-02-24 15:54:57 +09:00
|
|
|
</Label>
|
2026-02-25 17:03:47 +09:00
|
|
|
<TableCombobox
|
|
|
|
|
tables={tables}
|
|
|
|
|
value={field.saveTable || ""}
|
|
|
|
|
onSelect={(tableName) =>
|
|
|
|
|
updateField({
|
|
|
|
|
saveTable: tableName || undefined,
|
|
|
|
|
saveColumn: undefined,
|
|
|
|
|
})
|
2026-02-24 15:54:57 +09:00
|
|
|
}
|
2026-02-25 17:03:47 +09:00
|
|
|
/>
|
2026-02-24 15:54:57 +09:00
|
|
|
</div>
|
2026-02-25 17:03:47 +09:00
|
|
|
|
|
|
|
|
{/* 저장 대상 컬럼 */}
|
|
|
|
|
{field.saveTable && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px] text-muted-foreground">
|
|
|
|
|
저장 대상 컬럼
|
|
|
|
|
</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={field.saveColumn || "__none__"}
|
|
|
|
|
onValueChange={(val) =>
|
|
|
|
|
updateField({ saveColumn: val === "__none__" ? undefined : val })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
|
|
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
|
|
|
|
{saveTableColumns.map((col) => (
|
|
|
|
|
<SelectItem key={col.name} value={col.name}>
|
|
|
|
|
{col.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
|
|
|
|
입력값이 저장될 DB 컬럼
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// ===== 포장등록 설정 섹션 =====
|
2026-02-24 15:54:57 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
import { PACKAGE_UNITS } from "./PackageUnitModal";
|
|
|
|
|
|
|
|
|
|
function PackageSettingsSection({
|
|
|
|
|
packageConfig,
|
2026-02-24 15:54:57 +09:00
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
2026-02-25 17:03:47 +09:00
|
|
|
packageConfig?: CardPackageConfig;
|
|
|
|
|
onUpdate: (config: CardPackageConfig) => void;
|
2026-02-24 15:54:57 +09:00
|
|
|
}) {
|
2026-02-25 17:03:47 +09:00
|
|
|
const config: CardPackageConfig = packageConfig || {
|
2026-02-24 15:54:57 +09:00
|
|
|
enabled: false,
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
const updateConfig = (partial: Partial<CardPackageConfig>) => {
|
|
|
|
|
onUpdate({ ...config, ...partial });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const enabledSet = new Set(config.enabledUnits ?? PACKAGE_UNITS.map((u) => u.value));
|
|
|
|
|
|
|
|
|
|
const toggleUnit = (value: string) => {
|
|
|
|
|
const next = new Set(enabledSet);
|
|
|
|
|
if (next.has(value)) {
|
|
|
|
|
next.delete(value);
|
|
|
|
|
} else {
|
|
|
|
|
next.add(value);
|
|
|
|
|
}
|
|
|
|
|
updateConfig({ enabledUnits: Array.from(next) });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const addCustomUnit = () => {
|
|
|
|
|
const existing = config.customUnits || [];
|
|
|
|
|
const id = `custom_${Date.now()}`;
|
|
|
|
|
updateConfig({
|
|
|
|
|
customUnits: [...existing, { id, label: "" }],
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateCustomUnit = (id: string, label: string) => {
|
|
|
|
|
const existing = config.customUnits || [];
|
|
|
|
|
updateConfig({
|
|
|
|
|
customUnits: existing.map((cu) => (cu.id === id ? { ...cu, label } : cu)),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeCustomUnit = (id: string) => {
|
|
|
|
|
const existing = config.customUnits || [];
|
|
|
|
|
updateConfig({
|
|
|
|
|
customUnits: existing.filter((cu) => cu.id !== id),
|
|
|
|
|
});
|
2026-02-24 15:54:57 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-02-25 17:03:47 +09:00
|
|
|
<Label className="text-[10px]">포장등록 사용</Label>
|
2026-02-24 15:54:57 +09:00
|
|
|
<Switch
|
2026-02-25 17:03:47 +09:00
|
|
|
checked={config.enabled}
|
|
|
|
|
onCheckedChange={(enabled) => updateConfig({ enabled })}
|
2026-02-24 15:54:57 +09:00
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{config.enabled && (
|
2026-02-24 15:54:57 +09:00
|
|
|
<>
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 기본 포장 단위 체크박스 */}
|
2026-02-24 15:54:57 +09:00
|
|
|
<div>
|
2026-02-25 17:03:47 +09:00
|
|
|
<Label className="text-[10px] text-muted-foreground">사용할 포장 단위</Label>
|
|
|
|
|
<div className="mt-1 grid grid-cols-3 gap-1.5">
|
|
|
|
|
{PACKAGE_UNITS.map((unit) => (
|
|
|
|
|
<button
|
|
|
|
|
key={unit.value}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => toggleUnit(unit.value)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-1.5 rounded-md border px-2 py-1.5 text-[10px] transition-colors",
|
|
|
|
|
enabledSet.has(unit.value)
|
|
|
|
|
? "border-primary bg-primary/5 text-primary"
|
|
|
|
|
: "border-muted text-muted-foreground hover:bg-muted/50"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span>{unit.emoji}</span>
|
|
|
|
|
<span>{unit.label}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-02-24 15:54:57 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 커스텀 단위 */}
|
2026-02-24 15:54:57 +09:00
|
|
|
<div>
|
2026-02-25 17:03:47 +09:00
|
|
|
<Label className="text-[10px] text-muted-foreground">커스텀 단위</Label>
|
|
|
|
|
<div className="mt-1 space-y-1.5">
|
|
|
|
|
{(config.customUnits || []).map((cu) => (
|
|
|
|
|
<div key={cu.id} className="flex items-center gap-1.5">
|
|
|
|
|
<Input
|
|
|
|
|
value={cu.label}
|
|
|
|
|
onChange={(e) => updateCustomUnit(cu.id, e.target.value)}
|
|
|
|
|
className="h-7 flex-1 text-xs"
|
|
|
|
|
placeholder="단위 이름 (예: 파렛트)"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-7 w-7 shrink-0"
|
|
|
|
|
onClick={() => removeCustomUnit(cu.id)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="mt-1.5 h-7 w-full text-[10px]"
|
|
|
|
|
onClick={addCustomUnit}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
단위 추가
|
|
|
|
|
</Button>
|
2026-02-24 15:54:57 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 계산 결과 안내 메시지 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label className="text-[10px]">계산 결과 안내 메시지</Label>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={config.showSummaryMessage !== false}
|
|
|
|
|
onCheckedChange={(checked) => updateConfig({ showSummaryMessage: checked })}
|
2026-02-24 15:54:57 +09:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-25 17:03:47 +09:00
|
|
|
<p className="text-muted-foreground text-[9px]">
|
|
|
|
|
포장 등록 시 계산 결과를 안내 메시지로 표시합니다
|
|
|
|
|
</p>
|
2026-02-24 15:54:57 +09:00
|
|
|
</>
|
2026-02-12 11:07:58 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// ===== 조인 설정 섹션 (테이블 선택 -> 컬럼 자동 매칭) =====
|
|
|
|
|
|
|
|
|
|
// 두 테이블 간 매칭 가능한 컬럼 쌍 찾기
|
|
|
|
|
function findMatchingColumns(
|
|
|
|
|
sourceCols: ColumnInfo[],
|
|
|
|
|
targetCols: ColumnInfo[],
|
|
|
|
|
): Array<{ source: string; target: string; confidence: "high" | "medium" }> {
|
|
|
|
|
const matches: Array<{ source: string; target: string; confidence: "high" | "medium" }> = [];
|
|
|
|
|
const sourceNames = sourceCols.map((c) => c.name);
|
|
|
|
|
const targetNames = targetCols.map((c) => c.name);
|
|
|
|
|
|
|
|
|
|
for (const src of sourceNames) {
|
|
|
|
|
for (const tgt of targetNames) {
|
|
|
|
|
// 정확히 같은 이름 (예: item_code = item_code)
|
|
|
|
|
if (src === tgt) {
|
|
|
|
|
matches.push({ source: src, target: tgt, confidence: "high" });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// 소스가 _id/_code/_no 로 끝나고, 타겟 테이블에 같은 이름이 있는 경우
|
|
|
|
|
// 예: source.customer_code -> target.customer_code (이미 위에서 처리됨)
|
|
|
|
|
// 소스 컬럼명이 타겟 컬럼명을 포함하거나, 타겟이 소스를 포함
|
|
|
|
|
const suffixes = ["_id", "_code", "_no", "_number", "_key"];
|
|
|
|
|
const srcBase = suffixes.reduce((name, s) => name.endsWith(s) ? name.slice(0, -s.length) : name, src);
|
|
|
|
|
const tgtBase = suffixes.reduce((name, s) => name.endsWith(s) ? name.slice(0, -s.length) : name, tgt);
|
|
|
|
|
if (srcBase && tgtBase && srcBase === tgtBase && src !== tgt) {
|
|
|
|
|
matches.push({ source: src, target: tgt, confidence: "medium" });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// confidence 높은 순 정렬
|
|
|
|
|
return matches.sort((a, b) => (a.confidence === "high" ? -1 : 1) - (b.confidence === "high" ? -1 : 1));
|
|
|
|
|
}
|
2026-02-12 11:07:58 +09:00
|
|
|
|
|
|
|
|
function JoinSettingsSection({
|
|
|
|
|
dataSource,
|
|
|
|
|
tables,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
dataSource: CardListDataSource;
|
|
|
|
|
tables: TableInfo[];
|
|
|
|
|
onUpdate: (partial: Partial<CardListDataSource>) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const joins = dataSource.joins || [];
|
|
|
|
|
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
|
2026-02-25 17:03:47 +09:00
|
|
|
const [targetColumnsMap, setTargetColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
|
2026-02-12 11:07:58 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (dataSource.tableName) {
|
|
|
|
|
fetchTableColumns(dataSource.tableName).then(setSourceColumns);
|
2026-02-25 17:03:47 +09:00
|
|
|
} else {
|
|
|
|
|
setSourceColumns([]);
|
2026-02-12 11:07:58 +09:00
|
|
|
}
|
|
|
|
|
}, [dataSource.tableName]);
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
const getTableLabel = (tableName: string) => {
|
|
|
|
|
const found = tables.find((t) => t.tableName === tableName);
|
|
|
|
|
return found?.displayName || tableName;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 대상 테이블 컬럼 로드 + 자동 매칭
|
|
|
|
|
const loadTargetAndAutoMatch = (index: number, join: CardColumnJoin, targetTable: string) => {
|
|
|
|
|
const updated = { ...join, targetTable, sourceColumn: "", targetColumn: "" };
|
|
|
|
|
|
|
|
|
|
const doMatch = (targetCols: ColumnInfo[]) => {
|
|
|
|
|
const matches = findMatchingColumns(sourceColumns, targetCols);
|
|
|
|
|
if (matches.length > 0) {
|
|
|
|
|
updated.sourceColumn = matches[0].source;
|
|
|
|
|
updated.targetColumn = matches[0].target;
|
|
|
|
|
}
|
|
|
|
|
const newJoins = [...joins];
|
|
|
|
|
newJoins[index] = updated;
|
|
|
|
|
onUpdate({ joins: newJoins });
|
2026-02-12 11:07:58 +09:00
|
|
|
};
|
2026-02-25 17:03:47 +09:00
|
|
|
|
|
|
|
|
if (targetColumnsMap[targetTable]) {
|
|
|
|
|
doMatch(targetColumnsMap[targetTable]);
|
|
|
|
|
} else {
|
|
|
|
|
fetchTableColumns(targetTable).then((cols) => {
|
|
|
|
|
setTargetColumnsMap((prev) => ({ ...prev, [targetTable]: cols }));
|
|
|
|
|
doMatch(cols);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-12 11:07:58 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateJoin = (index: number, updated: CardColumnJoin) => {
|
|
|
|
|
const newJoins = [...joins];
|
|
|
|
|
newJoins[index] = updated;
|
|
|
|
|
onUpdate({ joins: newJoins });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deleteJoin = (index: number) => {
|
|
|
|
|
const newJoins = joins.filter((_, i) => i !== index);
|
|
|
|
|
onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined });
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
const addJoin = () => {
|
|
|
|
|
onUpdate({ joins: [...joins, { targetTable: "", joinType: "LEFT", sourceColumn: "", targetColumn: "" }] });
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-12 11:07:58 +09:00
|
|
|
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">
|
2026-02-25 17:03:47 +09:00
|
|
|
다른 테이블을 연결하면 추가 정보를 카드에 표시할 수 있습니다
|
2026-02-12 11:07:58 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-2">
|
2026-02-25 17:03:47 +09:00
|
|
|
{joins.map((join, index) => {
|
|
|
|
|
const targetCols = targetColumnsMap[join.targetTable] || [];
|
|
|
|
|
const matchingPairs = join.targetTable
|
|
|
|
|
? findMatchingColumns(sourceColumns, targetCols)
|
|
|
|
|
: [];
|
|
|
|
|
const hasAutoMatch = join.sourceColumn && join.targetColumn;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<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">
|
|
|
|
|
{join.targetTable ? getTableLabel(join.targetTable) : "테이블 선택"}
|
|
|
|
|
</span>
|
|
|
|
|
<Button variant="ghost" size="icon" className="h-5 w-5 text-destructive" onClick={() => deleteJoin(index)}>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 대상 테이블 선택 (검색 가능) */}
|
|
|
|
|
<TableCombobox
|
|
|
|
|
tables={tables.filter((t) => t.tableName !== dataSource.tableName)}
|
|
|
|
|
value={join.targetTable || ""}
|
|
|
|
|
onSelect={(val) => loadTargetAndAutoMatch(index, join, val)}
|
|
|
|
|
/>
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{/* 자동 매칭 결과 또는 수동 선택 */}
|
|
|
|
|
{join.targetTable && (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{/* 자동 매칭 성공: 결과 표시 */}
|
|
|
|
|
{hasAutoMatch ? (
|
|
|
|
|
<div className="flex items-center gap-1.5 rounded-md bg-primary/5 border border-primary/20 px-2 py-1.5">
|
|
|
|
|
<Check className="h-3 w-3 shrink-0 text-primary" />
|
|
|
|
|
<span className="text-[10px] text-primary">
|
|
|
|
|
자동 연결됨: {join.sourceColumn} = {join.targetColumn}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="rounded-md bg-muted/50 px-2 py-1.5">
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">
|
|
|
|
|
자동 매칭되는 컬럼이 없습니다. 직접 선택하세요.
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 다른 매칭 후보가 있으면 표시 */}
|
|
|
|
|
{matchingPairs.length > 1 && (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<span className="text-[9px] text-muted-foreground">다른 연결 기준:</span>
|
|
|
|
|
{matchingPairs.map((pair) => {
|
|
|
|
|
const isActive = join.sourceColumn === pair.source && join.targetColumn === pair.target;
|
|
|
|
|
if (isActive) return null;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={`${pair.source}-${pair.target}`}
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex w-full items-center gap-1.5 rounded border border-input px-2 py-1 text-left text-[9px] transition-colors hover:bg-accent/50"
|
|
|
|
|
onClick={() => updateJoin(index, { ...join, sourceColumn: pair.source, targetColumn: pair.target })}
|
|
|
|
|
>
|
|
|
|
|
<span>{pair.source} = {pair.target}</span>
|
|
|
|
|
{pair.confidence === "high" && (
|
|
|
|
|
<span className="rounded bg-primary/10 px-1 text-[8px] text-primary">정확</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 수동 선택 (펼치기) */}
|
|
|
|
|
<details className="group">
|
|
|
|
|
<summary className="cursor-pointer text-[9px] text-muted-foreground hover:text-foreground">
|
|
|
|
|
직접 컬럼 선택
|
|
|
|
|
</summary>
|
|
|
|
|
<div className="mt-1.5 flex items-center gap-1">
|
|
|
|
|
<Select
|
|
|
|
|
value={join.sourceColumn || ""}
|
|
|
|
|
onValueChange={(val) => updateJoin(index, { ...join, sourceColumn: val })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]">
|
|
|
|
|
<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 flex-1 text-[10px]">
|
|
|
|
|
<SelectValue placeholder="대상 컬럼" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{targetCols.map((col) => (
|
|
|
|
|
<SelectItem key={col.name} value={col.name}>{col.name}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
<Button variant="outline" size="sm" className="w-full text-xs" onClick={addJoin}>
|
2026-02-12 11:07:58 +09:00
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
2026-02-25 17:03:47 +09:00
|
|
|
테이블 연결 추가
|
2026-02-12 11:07:58 +09:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
// ===== 정렬 설정 섹션 (다중 정렬) =====
|
2026-02-12 11:07:58 +09:00
|
|
|
|
|
|
|
|
function SortSettingsSection({
|
|
|
|
|
dataSource,
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups,
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
dataSource: CardListDataSource;
|
2026-02-25 17:03:47 +09:00
|
|
|
columnGroups: ColumnGroup[];
|
2026-02-12 11:07:58 +09:00
|
|
|
onUpdate: (partial: Partial<CardListDataSource>) => void;
|
|
|
|
|
}) {
|
2026-02-25 17:03:47 +09:00
|
|
|
// 하위 호환: 이전 형식(단일 객체)이 저장되어 있을 수 있음
|
|
|
|
|
const sorts: CardSortConfig[] = Array.isArray(dataSource.sort)
|
|
|
|
|
? dataSource.sort
|
|
|
|
|
: dataSource.sort && typeof dataSource.sort === "object"
|
|
|
|
|
? [dataSource.sort as CardSortConfig]
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const addSort = () => {
|
|
|
|
|
onUpdate({ sort: [...sorts, { column: "", direction: "desc" }] });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateSort = (index: number, updated: CardSortConfig) => {
|
|
|
|
|
const newSorts = [...sorts];
|
|
|
|
|
newSorts[index] = updated;
|
|
|
|
|
onUpdate({ sort: newSorts });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deleteSort = (index: number) => {
|
|
|
|
|
const newSorts = sorts.filter((_, i) => i !== index);
|
|
|
|
|
onUpdate({ sort: newSorts.length > 0 ? newSorts : undefined });
|
|
|
|
|
};
|
2026-02-12 11:07:58 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
2026-02-25 17:03:47 +09:00
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
화면 로드 시 적용되는 기본 정렬 순서입니다. 위에 있는 항목이 우선 적용됩니다.
|
|
|
|
|
</p>
|
2026-02-12 11:07:58 +09:00
|
|
|
|
2026-02-25 17:03:47 +09:00
|
|
|
{sorts.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>
|
|
|
|
|
) : (
|
2026-02-12 11:07:58 +09:00
|
|
|
<div className="space-y-2">
|
2026-02-25 17:03:47 +09:00
|
|
|
{sorts.map((sort, index) => (
|
|
|
|
|
<div key={index} className="flex items-center gap-1.5">
|
|
|
|
|
<span className="shrink-0 text-[10px] text-muted-foreground w-3 text-right">
|
|
|
|
|
{index + 1}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<GroupedColumnSelect
|
|
|
|
|
columnGroups={columnGroups}
|
|
|
|
|
value={sort.column || undefined}
|
|
|
|
|
onValueChange={(val) => updateSort(index, { ...sort, column: val || "" })}
|
|
|
|
|
placeholder="컬럼 선택"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-12 11:07:58 +09:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-02-25 17:03:47 +09:00
|
|
|
className={`shrink-0 rounded border px-2 py-1 text-[10px] ${
|
2026-02-12 11:07:58 +09:00
|
|
|
sort.direction === "asc"
|
2026-02-25 17:03:47 +09:00
|
|
|
? "border-primary bg-primary/10 text-primary"
|
|
|
|
|
: "border-input text-muted-foreground hover:border-primary/50"
|
2026-02-12 11:07:58 +09:00
|
|
|
}`}
|
2026-02-25 17:03:47 +09:00
|
|
|
onClick={() => updateSort(index, { ...sort, direction: "asc" })}
|
2026-02-12 11:07:58 +09:00
|
|
|
>
|
2026-02-25 17:03:47 +09:00
|
|
|
오름
|
2026-02-12 11:07:58 +09:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-02-25 17:03:47 +09:00
|
|
|
className={`shrink-0 rounded border px-2 py-1 text-[10px] ${
|
2026-02-12 11:07:58 +09:00
|
|
|
sort.direction === "desc"
|
2026-02-25 17:03:47 +09:00
|
|
|
? "border-primary bg-primary/10 text-primary"
|
|
|
|
|
: "border-input text-muted-foreground hover:border-primary/50"
|
2026-02-12 11:07:58 +09:00
|
|
|
}`}
|
2026-02-25 17:03:47 +09:00
|
|
|
onClick={() => updateSort(index, { ...sort, direction: "desc" })}
|
2026-02-12 11:07:58 +09:00
|
|
|
>
|
2026-02-25 17:03:47 +09:00
|
|
|
내림
|
2026-02-12 11:07:58 +09:00
|
|
|
</button>
|
2026-02-25 17:03:47 +09:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-5 w-5 shrink-0 text-destructive"
|
|
|
|
|
onClick={() => deleteSort(index)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
2026-02-25 17:03:47 +09:00
|
|
|
))}
|
2026-02-12 11:07:58 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-25 17:03:47 +09:00
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full text-xs"
|
|
|
|
|
onClick={addSort}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
정렬 기준 추가
|
|
|
|
|
</Button>
|
2026-02-12 11:07:58 +09:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-24 15:54:57 +09:00
|
|
|
|
|
|
|
|
// ===== 담기 버튼 설정 섹션 =====
|
|
|
|
|
|
|
|
|
|
function CartActionSettingsSection({
|
|
|
|
|
cartAction,
|
|
|
|
|
onUpdate,
|
2026-02-26 16:00:07 +09:00
|
|
|
cardTemplate,
|
|
|
|
|
tableName,
|
2026-02-24 15:54:57 +09:00
|
|
|
}: {
|
|
|
|
|
cartAction?: CardCartActionConfig;
|
|
|
|
|
onUpdate: (cartAction: CardCartActionConfig) => void;
|
2026-02-26 16:00:07 +09:00
|
|
|
cardTemplate?: CardTemplateConfig;
|
|
|
|
|
tableName?: string;
|
2026-02-24 15:54:57 +09:00
|
|
|
}) {
|
|
|
|
|
const action: CardCartActionConfig = cartAction || {
|
2026-02-26 16:00:07 +09:00
|
|
|
saveMode: "cart",
|
2026-02-24 15:54:57 +09:00
|
|
|
label: "담기",
|
|
|
|
|
cancelLabel: "취소",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const update = (partial: Partial<CardCartActionConfig>) => {
|
|
|
|
|
onUpdate({ ...action, ...partial });
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
const saveMode = action.saveMode || "cart";
|
|
|
|
|
|
|
|
|
|
// 카드 템플릿에서 사용 중인 필드 목록 수집
|
|
|
|
|
const usedFields = useMemo(() => {
|
|
|
|
|
const fields: { name: string; label: string; source: string }[] = [];
|
|
|
|
|
|
|
|
|
|
if (cardTemplate?.header?.codeField) {
|
|
|
|
|
fields.push({ name: cardTemplate.header.codeField, label: "코드 (헤더)", source: "헤더" });
|
|
|
|
|
}
|
|
|
|
|
if (cardTemplate?.header?.titleField) {
|
|
|
|
|
fields.push({ name: cardTemplate.header.titleField, label: "제목 (헤더)", source: "헤더" });
|
|
|
|
|
}
|
|
|
|
|
if (cardTemplate?.body?.fields) {
|
|
|
|
|
for (const f of cardTemplate.body.fields) {
|
|
|
|
|
if (f.valueType === "column" && f.columnName) {
|
|
|
|
|
fields.push({ name: f.columnName, label: f.label || f.columnName, source: "본문" });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fields;
|
|
|
|
|
}, [cardTemplate]);
|
|
|
|
|
|
2026-02-24 15:54:57 +09:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
2026-02-26 16:00:07 +09:00
|
|
|
{/* 저장 방식 */}
|
2026-02-24 15:54:57 +09:00
|
|
|
<div>
|
2026-02-26 16:00:07 +09:00
|
|
|
<Label className="text-[10px]">저장 방식</Label>
|
2026-02-24 15:54:57 +09:00
|
|
|
<Select
|
2026-02-26 16:00:07 +09:00
|
|
|
value={saveMode}
|
|
|
|
|
onValueChange={(v) => update({ saveMode: v as "cart" | "direct" })}
|
2026-02-24 15:54:57 +09:00
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-02-26 16:00:07 +09:00
|
|
|
<SelectItem value="cart" className="text-xs">장바구니에 담기</SelectItem>
|
|
|
|
|
<SelectItem value="direct" className="text-xs">바로 저장 (향후 지원)</SelectItem>
|
2026-02-24 15:54:57 +09:00
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-26 16:00:07 +09:00
|
|
|
{/* 장바구니 구분값 */}
|
|
|
|
|
{saveMode === "cart" && (
|
2026-02-24 15:54:57 +09:00
|
|
|
<div>
|
2026-02-26 16:00:07 +09:00
|
|
|
<Label className="text-[10px]">장바구니 구분값</Label>
|
2026-02-24 15:54:57 +09:00
|
|
|
<Input
|
2026-02-26 16:00:07 +09:00
|
|
|
value={action.cartType || ""}
|
|
|
|
|
onChange={(e) => update({ cartType: e.target.value })}
|
|
|
|
|
placeholder="예: purchase_inbound"
|
2026-02-24 15:54:57 +09:00
|
|
|
className="mt-1 h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
2026-02-26 16:00:07 +09:00
|
|
|
장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다.
|
2026-02-24 15:54:57 +09:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 담기 라벨 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">담기 라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={action.label || ""}
|
|
|
|
|
onChange={(e) => update({ label: e.target.value })}
|
|
|
|
|
placeholder="담기"
|
|
|
|
|
className="mt-1 h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 취소 라벨 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">취소 라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={action.cancelLabel || ""}
|
|
|
|
|
onChange={(e) => update({ cancelLabel: e.target.value })}
|
|
|
|
|
placeholder="취소"
|
|
|
|
|
className="mt-1 h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-26 16:00:07 +09:00
|
|
|
|
|
|
|
|
{/* 저장 데이터 정보 (읽기 전용) */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-[10px]">저장 데이터 정보</Label>
|
|
|
|
|
<div className="rounded-md border bg-muted/30 p-2">
|
|
|
|
|
<p className="text-muted-foreground text-[10px]">
|
|
|
|
|
담기 시 <span className="font-medium text-foreground">{tableName || "(테이블 미선택)"}</span>의
|
|
|
|
|
모든 컬럼 데이터가 JSON으로 저장됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{usedFields.length > 0 && (
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<p className="text-muted-foreground mb-1 text-[10px]">카드에 표시 중인 필드:</p>
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
{usedFields.map((f) => (
|
|
|
|
|
<div key={`${f.source}-${f.name}`} className="flex items-center gap-1.5 text-[10px]">
|
|
|
|
|
<span className="inline-block w-8 rounded bg-muted px-1 text-center text-[9px] text-muted-foreground">
|
|
|
|
|
{f.source}
|
|
|
|
|
</span>
|
|
|
|
|
<code className="font-mono text-foreground">{f.name}</code>
|
|
|
|
|
<span className="text-muted-foreground">- {f.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<p className="text-muted-foreground mt-2 text-[10px]">
|
|
|
|
|
+ 입력 수량, 포장 단위 등 추가 정보도 함께 저장됩니다.
|
|
|
|
|
<br />
|
|
|
|
|
장바구니 목록 화면에서 대상 테이블로 매핑 설정이 가능합니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-24 15:54:57 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-25 17:03:47 +09:00
|
|
|
|
|
|
|
|
// ===== 반응형 표시 설정 섹션 =====
|
|
|
|
|
|
|
|
|
|
const RESPONSIVE_MODES: ResponsiveDisplayMode[] = ["required", "shrink", "hidden"];
|
|
|
|
|
|
|
|
|
|
function ResponsiveDisplaySection({
|
|
|
|
|
config,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
config: PopCardListConfig;
|
|
|
|
|
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const template = config.cardTemplate || { header: {}, image: { enabled: false }, body: { fields: [] } };
|
|
|
|
|
const responsive = config.responsiveDisplay || {};
|
|
|
|
|
const bodyFields = template.body?.fields || [];
|
|
|
|
|
|
|
|
|
|
const hasHeader = !!template.header?.codeField || !!template.header?.titleField;
|
|
|
|
|
const hasImage = !!template.image?.enabled;
|
|
|
|
|
const hasFields = bodyFields.length > 0;
|
|
|
|
|
|
|
|
|
|
const updateResponsive = (partial: Partial<CardResponsiveConfig>) => {
|
|
|
|
|
onUpdate({ responsiveDisplay: { ...responsive, ...partial } });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateFieldMode = (fieldId: string, mode: ResponsiveDisplayMode) => {
|
|
|
|
|
updateResponsive({
|
|
|
|
|
fields: { ...(responsive.fields || {}), [fieldId]: mode },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!hasHeader && !hasImage && !hasFields) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
카드 템플릿에 항목을 먼저 추가하세요
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
화면이 좁아질 때 각 항목이 어떻게 표시될지 설정합니다.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-3 rounded-md bg-muted/30 px-2.5 py-1.5">
|
|
|
|
|
{RESPONSIVE_MODES.map((mode) => (
|
|
|
|
|
<span key={mode} className="text-[10px] text-muted-foreground">
|
|
|
|
|
<span className="font-medium">{RESPONSIVE_DISPLAY_LABELS[mode]}</span>
|
|
|
|
|
{mode === "required" && " = 항상 표시"}
|
|
|
|
|
{mode === "shrink" && " = 축소 가능"}
|
|
|
|
|
{mode === "hidden" && " = 숨김 가능"}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{template.header?.codeField && (
|
|
|
|
|
<ResponsiveDisplayRow
|
|
|
|
|
label="코드"
|
|
|
|
|
sublabel={template.header.codeField}
|
|
|
|
|
value={responsive.code || "required"}
|
|
|
|
|
onChange={(mode) => updateResponsive({ code: mode })}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{template.header?.titleField && (
|
|
|
|
|
<ResponsiveDisplayRow
|
|
|
|
|
label="제목"
|
|
|
|
|
sublabel={template.header.titleField}
|
|
|
|
|
value={responsive.title || "required"}
|
|
|
|
|
onChange={(mode) => updateResponsive({ title: mode })}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{hasImage && (
|
|
|
|
|
<ResponsiveDisplayRow
|
|
|
|
|
label="이미지"
|
|
|
|
|
value={responsive.image || "shrink"}
|
|
|
|
|
onChange={(mode) => updateResponsive({ image: mode })}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{bodyFields.map((field) => (
|
|
|
|
|
<ResponsiveDisplayRow
|
|
|
|
|
key={field.id}
|
|
|
|
|
label={field.label || "(라벨 없음)"}
|
|
|
|
|
sublabel={field.columnName}
|
|
|
|
|
value={responsive.fields?.[field.id] || "shrink"}
|
|
|
|
|
onChange={(mode) => updateFieldMode(field.id, mode)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ResponsiveDisplayRow({
|
|
|
|
|
label,
|
|
|
|
|
sublabel,
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
sublabel?: string;
|
|
|
|
|
value: ResponsiveDisplayMode;
|
|
|
|
|
onChange: (mode: ResponsiveDisplayMode) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-2 rounded-md border bg-card px-2 py-1.5">
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<span className="text-xs font-medium truncate block">{label}</span>
|
|
|
|
|
{sublabel && (
|
|
|
|
|
<span className="text-[10px] text-muted-foreground truncate block">
|
|
|
|
|
{sublabel}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex shrink-0 gap-0.5">
|
|
|
|
|
{RESPONSIVE_MODES.map((mode) => (
|
|
|
|
|
<button
|
|
|
|
|
key={mode}
|
|
|
|
|
type="button"
|
|
|
|
|
className={`rounded px-2 py-0.5 text-[10px] transition-colors ${
|
|
|
|
|
value === mode
|
|
|
|
|
? mode === "required"
|
|
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
: mode === "shrink"
|
|
|
|
|
? "bg-amber-500/20 text-amber-700 border border-amber-500/30"
|
|
|
|
|
: "bg-muted text-muted-foreground border border-input"
|
|
|
|
|
: "text-muted-foreground hover:bg-muted/50"
|
|
|
|
|
}`}
|
|
|
|
|
onClick={() => onChange(mode)}
|
|
|
|
|
>
|
|
|
|
|
{RESPONSIVE_DISPLAY_LABELS[mode]}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|