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

3129 lines
103 KiB
TypeScript

"use client";
/**
* pop-card-list 설정 패널
*
* 2개 탭:
* [기본 설정] - 테이블 선택 + 조인/정렬 + 레이아웃 설정
* [카드 템플릿] - 헤더/이미지/본문/입력/계산/담기 설정
*/
import React, { useState, useEffect, useMemo } from "react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
import type { GridMode } from "@/components/pop/designer/types/pop-layout";
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
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,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type {
PopCardListConfig,
CardListDataSource,
CardSortConfig,
CardTemplateConfig,
CardHeaderConfig,
CardImageConfig,
CardBodyConfig,
CardFieldBinding,
FieldValueType,
FormulaOperator,
FormulaRightType,
CardColumnJoin,
CardColumnFilter,
CardScrollDirection,
FilterOperator,
CardInputFieldConfig,
CardPackageConfig,
CardCartActionConfig,
CardResponsiveConfig,
ResponsiveDisplayMode,
CartListModeConfig,
CardListSaveMapping,
CardListSaveMappingEntry,
} from "../types";
import { screenApi } from "@/lib/api/screen";
import {
CARD_SCROLL_DIRECTION_LABELS,
RESPONSIVE_DISPLAY_LABELS,
DEFAULT_CARD_IMAGE,
} from "../types";
import {
fetchTableList,
fetchTableColumns,
type TableInfo,
type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher";
import { TableCombobox } from "../pop-shared/TableCombobox";
// ===== 테이블별 그룹화된 컬럼 =====
interface ColumnGroup {
tableName: string;
displayName: string;
columns: ColumnInfo[];
}
// ===== Props =====
interface ConfigPanelProps {
config: PopCardListConfig | undefined;
onUpdate: (config: PopCardListConfig) => void;
currentMode?: GridMode;
currentColSpan?: number;
}
// ===== 기본값 =====
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,
scrollDirection: "vertical",
gridColumns: 2,
gridRows: 3,
cardSize: "large",
};
// ===== 색상 옵션 (본문 필드 텍스트 색상) =====
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, currentMode, currentColSpan }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<"basic" | "template">("basic");
const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
const updateConfig = (partial: Partial<PopCardListConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const isCartListMode = !!cfg.cartListMode?.enabled;
const hasTable = !!cfg.dataSource?.tableName;
return (
<div className="flex h-full flex-col">
{/* 탭 헤더 - 2탭 */}
<div className="flex border-b">
<button
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
activeTab === "basic"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab("basic")}
>
</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 && !isCartListMode
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground/50 cursor-not-allowed"
}`}
onClick={() => hasTable && !isCartListMode && setActiveTab("template")}
disabled={!hasTable || isCartListMode}
>
릿
</button>
</div>
{/* 탭 내용 */}
<div className="flex-1 overflow-y-auto p-3">
{activeTab === "basic" && (
<BasicSettingsTab
config={cfg}
onUpdate={updateConfig}
currentMode={currentMode}
currentColSpan={currentColSpan}
/>
)}
{activeTab === "template" && (
isCartListMode ? (
<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>
) : (
<CardTemplateTab config={cfg} onUpdate={updateConfig} />
)
)}
</div>
</div>
);
}
// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
function BasicSettingsTab({
config,
onUpdate,
currentMode,
currentColSpan,
}: {
config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void;
currentMode?: GridMode;
currentColSpan?: number;
}) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [joinColumnsMap, setJoinColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
useEffect(() => {
fetchTableList().then(setTables);
}, []);
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
const recommendation = useMemo(() => {
if (!currentMode) return null;
const cols = GRID_BREAKPOINTS[currentMode].columns;
if (cols >= 8) return { rows: 3, cols: 2 };
if (cols >= 6) return { rows: 3, cols: 1 };
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
const isCartListMode = !!config.cartListMode?.enabled;
const updateDataSource = (partial: Partial<CardListDataSource>) => {
onUpdate({ dataSource: { ...dataSource, ...partial } });
};
return (
<div className="space-y-4">
{/* 장바구니 목록 모드 */}
<CollapsibleSection title="장바구니 목록 모드" defaultOpen={isCartListMode}>
<CartListModeSection
cartListMode={config.cartListMode}
onUpdate={(cartListMode) => onUpdate({ cartListMode })}
/>
</CollapsibleSection>
{/* 테이블 선택 (장바구니 모드 시 숨김) */}
{!isCartListMode && (
<CollapsibleSection title="테이블 선택" defaultOpen>
<div className="space-y-3">
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<TableCombobox
tables={tables}
value={dataSource.tableName || ""}
onSelect={(val) => {
onUpdate({
dataSource: {
tableName: val,
joins: undefined,
filters: undefined,
sort: undefined,
limit: undefined,
},
cardTemplate: DEFAULT_TEMPLATE,
});
}}
/>
</div>
{dataSource.tableName && (
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
<Database className="h-4 w-4 text-primary" />
<span className="text-xs font-medium">{dataSource.tableName}</span>
</div>
)}
</div>
</CollapsibleSection>
)}
{/* 조인 설정 (장바구니 모드 시 숨김) */}
{!isCartListMode && dataSource.tableName && (
<CollapsibleSection
title="조인 설정"
badge={
dataSource.joins && dataSource.joins.length > 0
? `${dataSource.joins.length}`
: undefined
}
>
<JoinSettingsSection
dataSource={dataSource}
tables={tables}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
)}
{/* 정렬 기준 (장바구니 모드 시 숨김) */}
{!isCartListMode && 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>
)}
{/* 필터 기준 (장바구니 모드 시 숨김) */}
{!isCartListMode && dataSource.tableName && (
<CollapsibleSection
title="필터 기준"
badge={
dataSource.filters && dataSource.filters.length > 0
? `${dataSource.filters.length}`
: undefined
}
>
<FilterCriteriaSection
dataSource={dataSource}
columnGroups={columnGroups}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
)}
{/* 저장 매핑 (장바구니 모드일 때만) */}
{isCartListMode && (
<CollapsibleSection
title="저장 매핑"
badge={
config.saveMapping?.mappings && config.saveMapping.mappings.length > 0
? `${config.saveMapping.mappings.length}`
: undefined
}
>
<SaveMappingSection
saveMapping={config.saveMapping}
onUpdate={(saveMapping) => onUpdate({ saveMapping })}
cartListMode={config.cartListMode}
/>
</CollapsibleSection>
)}
{/* 레이아웃 설정 */}
<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}
/>
</div>
<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>
</div>
</div>
</CollapsibleSection>
</div>
);
}
// (DataSourceTab 제거됨 - 조인/정렬 설정이 BasicSettingsTab으로 통합)
// ===== 카드 템플릿 탭 =====
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 [tables, setTables] = useState<TableInfo[]>([]);
const [mainColumns, setMainColumns] = useState<ColumnInfo[]>([]);
const [joinColumnsMap, setJoinColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
// 테이블 목록 로드 (한글명 표시용)
useEffect(() => {
fetchTableList().then(setTables);
}, []);
// 메인 테이블 컬럼 로드
useEffect(() => {
if (dataSource.tableName) {
fetchTableColumns(dataSource.tableName).then(setMainColumns);
} else {
setMainColumns([]);
}
}, [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 && mainColumns.length > 0) {
groups.push({
tableName: dataSource.tableName,
displayName: getTableDisplayName(dataSource.tableName),
columns: mainColumns,
});
}
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],
});
}
});
return groups;
}, [dataSource.tableName, mainColumns, dataSource.joins, joinColumnsMap, tables]); // eslint-disable-line react-hooks/exhaustive-deps
// 하위 호환: 단일 배열 (조인 없는 섹션용)
const columns = mainColumns;
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}
columnGroups={columnGroups}
onUpdate={(header) => updateTemplate({ header })}
/>
</CollapsibleSection>
{/* 이미지 설정 */}
<CollapsibleSection title="이미지 설정" defaultOpen>
<ImageSettingsSection
image={template.image || DEFAULT_IMAGE}
columnGroups={columnGroups}
onUpdate={(image) => updateTemplate({ image })}
/>
</CollapsibleSection>
{/* 본문 필드 */}
<CollapsibleSection
title="본문 필드"
badge={`${template.body?.fields?.length || 0}`}
defaultOpen
>
<BodyFieldsSection
body={template.body || DEFAULT_BODY}
columnGroups={columnGroups}
onUpdate={(body) => updateTemplate({ body })}
/>
</CollapsibleSection>
{/* 입력 필드 설정 */}
<CollapsibleSection title="입력 필드" defaultOpen={false}>
<InputFieldSettingsSection
inputField={config.inputField}
columns={columns}
tables={tables}
onUpdate={(inputField) => onUpdate({ inputField })}
/>
</CollapsibleSection>
{/* 포장등록 설정 */}
<CollapsibleSection title="포장등록 (계산기)" defaultOpen={false}>
<PackageSettingsSection
packageConfig={config.packageConfig}
onUpdate={(packageConfig) => onUpdate({ packageConfig })}
/>
</CollapsibleSection>
{/* 담기 버튼 설정 */}
<CollapsibleSection title="담기 버튼" defaultOpen={false}>
<CartActionSettingsSection
cartAction={config.cartAction}
onUpdate={(cartAction) => onUpdate({ cartAction })}
cardTemplate={template}
tableName={dataSource.tableName}
/>
</CollapsibleSection>
{/* 반응형 표시 설정 */}
<CollapsibleSection title="반응형 표시" defaultOpen={false}>
<ResponsiveDisplaySection
config={config}
onUpdate={onUpdate}
/>
</CollapsibleSection>
</div>
);
}
// TableCombobox: pop-shared/TableCombobox.tsx에서 import
// ===== 테이블별 그룹화된 컬럼 셀렉트 =====
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>
);
}
// ===== 접기/펴기 섹션 컴포넌트 =====
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>
);
}
// ===== 장바구니 목록 모드 설정 =====
interface SourceCardListInfo {
componentId: string;
label: string;
}
function CartListModeSection({
cartListMode,
onUpdate,
}: {
cartListMode?: CartListModeConfig;
onUpdate: (config: CartListModeConfig) => void;
}) {
const mode: CartListModeConfig = cartListMode || { enabled: false };
const [screens, setScreens] = useState<{ id: number; name: string }[]>([]);
const [sourceCardLists, setSourceCardLists] = useState<SourceCardListInfo[]>([]);
const [loadingComponents, setLoadingComponents] = useState(false);
// 화면 목록 로드
useEffect(() => {
screenApi
.getScreens({ size: 500 })
.then((res) => {
if (res?.data) {
setScreens(
res.data.map((s) => ({
id: s.screenId,
name: s.screenName || `화면 ${s.screenId}`,
}))
);
}
})
.catch(() => {});
}, []);
// 원본 화면 선택 시 -> 해당 화면의 pop-card-list 컴포넌트 목록 로드
useEffect(() => {
if (!mode.sourceScreenId) {
setSourceCardLists([]);
return;
}
setLoadingComponents(true);
screenApi
.getLayoutPop(mode.sourceScreenId)
.then((layoutJson: any) => {
const componentsMap = layoutJson?.components || {};
const componentList = Object.values(componentsMap) as any[];
const cardLists: SourceCardListInfo[] = componentList
.filter((c: any) => c.type === "pop-card-list")
.map((c: any) => ({
componentId: c.id || "",
label: c.label || "카드 목록",
}));
setSourceCardLists(cardLists);
})
.catch(() => {
setSourceCardLists([]);
})
.finally(() => setLoadingComponents(false));
}, [mode.sourceScreenId]);
const handleScreenChange = (val: string) => {
const screenId = val === "__none__" ? undefined : Number(val);
onUpdate({ ...mode, sourceScreenId: screenId });
};
const handleComponentSelect = (val: string) => {
if (val === "__none__") {
onUpdate({ ...mode, sourceComponentId: undefined });
return;
}
const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val;
const found = sourceCardLists.find((c) => c.componentId === compId);
if (found) {
onUpdate({ ...mode, sourceComponentId: found.componentId });
}
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={mode.enabled}
onCheckedChange={(enabled) => onUpdate({ ...mode, enabled })}
/>
</div>
<p className="text-[9px] text-muted-foreground">
cart_items ,
.
</p>
{mode.enabled && (
<>
{/* 원본 화면 선택 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={mode.sourceScreenId ? String(mode.sourceScreenId) : "__none__"}
onValueChange={handleScreenChange}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{screens.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 원본 컴포넌트 선택 (원본 화면에서 자동 로드) */}
{mode.sourceScreenId && (
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
{loadingComponents ? (
<div className="mt-1 flex h-7 items-center px-2 text-xs text-muted-foreground">
...
</div>
) : sourceCardLists.length === 0 ? (
<div className="mt-1 rounded-md border border-dashed bg-muted/30 px-2 py-1.5 text-[10px] text-muted-foreground">
.
</div>
) : (
<Select
value={mode.sourceComponentId ? `__comp_${mode.sourceComponentId}` : "__none__"}
onValueChange={handleComponentSelect}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="카드 목록 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{sourceCardLists.map((c) => (
<SelectItem key={c.componentId} value={`__comp_${c.componentId}`}>
{c.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="mt-1 text-[9px] text-muted-foreground">
.
</p>
</div>
)}
</>
)}
</div>
);
}
// ===== 헤더 설정 섹션 =====
function HeaderSettingsSection({
header,
columnGroups,
onUpdate,
}: {
header: CardHeaderConfig;
columnGroups: ColumnGroup[];
onUpdate: (header: CardHeaderConfig) => void;
}) {
return (
<div className="space-y-3">
{/* 코드 필드 */}
<div>
<Label className="text-[10px]"> </Label>
<GroupedColumnSelect
columnGroups={columnGroups}
value={header.codeField}
onValueChange={(val) => onUpdate({ ...header, codeField: val })}
placeholder="컬럼 선택 (선택사항)"
allowNone
noneLabel="선택 안함"
className="mt-1"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(: ITEM032)
</p>
</div>
{/* 제목 필드 */}
<div>
<Label className="text-[10px]"> </Label>
<GroupedColumnSelect
columnGroups={columnGroups}
value={header.titleField}
onValueChange={(val) => onUpdate({ ...header, titleField: val })}
placeholder="컬럼 선택 (선택사항)"
allowNone
noneLabel="선택 안함"
className="mt-1"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
(: 너트 M10)
</p>
</div>
</div>
);
}
// ===== 이미지 설정 섹션 =====
function ImageSettingsSection({
image,
columnGroups,
onUpdate,
}: {
image: CardImageConfig;
columnGroups: ColumnGroup[];
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>
<GroupedColumnSelect
columnGroups={columnGroups}
value={image.imageColumn}
onValueChange={(val) => onUpdate({ ...image, imageColumn: val })}
placeholder="컬럼 선택 (선택사항)"
allowNone
noneLabel="선택 안함 (기본 이미지 사용)"
className="mt-1"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
DB에서 URL을 . URL이
</p>
</div>
</>
)}
</div>
);
}
// ===== 본문 필드 섹션 =====
function BodyFieldsSection({
body,
columnGroups,
onUpdate,
}: {
body: CardBodyConfig;
columnGroups: ColumnGroup[];
onUpdate: (body: CardBodyConfig) => void;
}) {
const fields = body.fields || [];
const addField = () => {
const newField: CardFieldBinding = {
id: `field-${Date.now()}`,
label: "",
valueType: "column",
columnName: "",
};
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}
columnGroups={columnGroups}
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>
);
}
// ===== 필드 편집기 =====
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: "입력값" },
];
function FieldEditor({
field,
index,
columnGroups,
totalCount,
onUpdate,
onDelete,
onMove,
}: {
field: CardFieldBinding;
index: number;
columnGroups: ColumnGroup[];
totalCount: number;
onUpdate: (field: CardFieldBinding) => void;
onDelete: () => void;
onMove: (direction: "up" | "down") => void;
}) {
const valueType = field.valueType || "column";
const rightType = field.formulaRightType || "input";
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">
{/* 1행: 라벨 + 값 유형 */}
<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="w-24">
<Label className="text-[10px]"> </Label>
<Select
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);
}}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALUE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 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__" && (
<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 InputFieldSettingsSection({
inputField,
columns,
tables,
onUpdate,
}: {
inputField?: CardInputFieldConfig;
columns: ColumnInfo[];
tables: TableInfo[];
onUpdate: (inputField: CardInputFieldConfig) => void;
}) {
const field = inputField || {
enabled: false,
unit: "EA",
};
// 하위 호환: maxColumn -> limitColumn 마이그레이션
const effectiveLimitColumn = field.limitColumn || field.maxColumn;
const updateField = (partial: Partial<CardInputFieldConfig>) => {
onUpdate({ ...field, ...partial });
};
// 저장 테이블 컬럼 로드
const [saveTableColumns, setSaveTableColumns] = useState<ColumnInfo[]>([]);
useEffect(() => {
if (field.saveTable) {
fetchTableColumns(field.saveTable).then(setSaveTableColumns);
} else {
setSaveTableColumns([]);
}
}, [field.saveTable]);
return (
<div className="space-y-3">
{/* 활성화 스위치 */}
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={field.enabled}
onCheckedChange={(enabled) => updateField({ enabled })}
/>
</div>
{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>
{/* 제한 기준 컬럼 */}
<div>
<Label className="text-[10px] text-muted-foreground">
</Label>
<Select
value={effectiveLimitColumn || "__none__"}
onValueChange={(val) =>
updateField({ limitColumn: val === "__none__" ? undefined : val, maxColumn: undefined })
}
>
<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-[9px] text-muted-foreground">
(: order_qty)
</p>
</div>
{/* 저장 대상 테이블 (검색 가능) */}
<div>
<Label className="text-[10px] text-muted-foreground">
</Label>
<TableCombobox
tables={tables}
value={field.saveTable || ""}
onSelect={(tableName) =>
updateField({
saveTable: tableName || undefined,
saveColumn: undefined,
})
}
/>
</div>
{/* 저장 대상 컬럼 */}
{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>
)}
</>
)}
</div>
);
}
// ===== 포장등록 설정 섹션 =====
import { PACKAGE_UNITS } from "./PackageUnitModal";
function PackageSettingsSection({
packageConfig,
onUpdate,
}: {
packageConfig?: CardPackageConfig;
onUpdate: (config: CardPackageConfig) => void;
}) {
const config: CardPackageConfig = packageConfig || {
enabled: false,
};
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),
});
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={config.enabled}
onCheckedChange={(enabled) => updateConfig({ enabled })}
/>
</div>
{config.enabled && (
<>
{/* 기본 포장 단위 체크박스 */}
<div>
<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>
</div>
{/* 커스텀 단위 */}
<div>
<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>
</div>
{/* 계산 결과 안내 메시지 */}
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={config.showSummaryMessage !== false}
onCheckedChange={(checked) => updateConfig({ showSummaryMessage: checked })}
/>
</div>
<p className="text-muted-foreground text-[9px]">
</p>
</>
)}
</div>
);
}
// ===== 조인 설정 섹션 (테이블 선택 -> 컬럼 자동 매칭) =====
// 두 테이블 간 매칭 가능한 컬럼 쌍 찾기
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));
}
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);
} else {
setSourceColumns([]);
}
}, [dataSource.tableName]);
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 });
};
if (targetColumnsMap[targetTable]) {
doMatch(targetColumnsMap[targetTable]);
} else {
fetchTableColumns(targetTable).then((cols) => {
setTargetColumnsMap((prev) => ({ ...prev, [targetTable]: cols }));
doMatch(cols);
});
}
};
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 });
};
const addJoin = () => {
onUpdate({ joins: [...joins, { targetTable: "", joinType: "LEFT", sourceColumn: "", targetColumn: "" }] });
};
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) => {
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>
{/* 대상 테이블 선택 (검색 가능) */}
<TableCombobox
tables={tables.filter((t) => t.tableName !== dataSource.tableName)}
value={join.targetTable || ""}
onSelect={(val) => loadTargetAndAutoMatch(index, join, val)}
/>
{/* 자동 매칭 결과 또는 수동 선택 */}
{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>
);
})}
</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,
columnGroups,
onUpdate,
}: {
dataSource: CardListDataSource;
columnGroups: ColumnGroup[];
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
// 하위 호환: 이전 형식(단일 객체)이 저장되어 있을 수 있음
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 });
};
return (
<div className="space-y-3">
<p className="text-[10px] text-muted-foreground">
. .
</p>
{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>
) : (
<div className="space-y-2">
{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>
<button
type="button"
className={`shrink-0 rounded border px-2 py-1 text-[10px] ${
sort.direction === "asc"
? "border-primary bg-primary/10 text-primary"
: "border-input text-muted-foreground hover:border-primary/50"
}`}
onClick={() => updateSort(index, { ...sort, direction: "asc" })}
>
</button>
<button
type="button"
className={`shrink-0 rounded border px-2 py-1 text-[10px] ${
sort.direction === "desc"
? "border-primary bg-primary/10 text-primary"
: "border-input text-muted-foreground hover:border-primary/50"
}`}
onClick={() => updateSort(index, { ...sort, direction: "desc" })}
>
</button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0 text-destructive"
onClick={() => deleteSort(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addSort}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</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>
);
}
// ===== 행 식별 키 컬럼 선택 =====
function KeyColumnSelect({
tableName,
value,
onValueChange,
}: {
tableName?: string;
value: string;
onValueChange: (v: string) => void;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
useEffect(() => {
if (tableName) {
fetchTableColumns(tableName).then(setColumns);
} else {
setColumns([]);
}
}, [tableName]);
const options = useMemo(() => {
const seen = new Set<string>();
const unique: ColumnInfo[] = [];
const hasId = columns.some((c) => c.name === "id");
if (!hasId) {
unique.push({ name: "id", type: "uuid", udtName: "uuid" });
seen.add("id");
}
for (const c of columns) {
if (!seen.has(c.name)) {
seen.add(c.name);
unique.push(c);
}
}
return unique;
}, [columns]);
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="id" />
</SelectTrigger>
<SelectContent>
{options.map((c) => (
<SelectItem key={c.name} value={c.name} className="text-xs">
{c.name === "id" ? "id (UUID, 기본)" : c.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ===== 담기 버튼 설정 섹션 =====
function CartActionSettingsSection({
cartAction,
onUpdate,
cardTemplate,
tableName,
}: {
cartAction?: CardCartActionConfig;
onUpdate: (cartAction: CardCartActionConfig) => void;
cardTemplate?: CardTemplateConfig;
tableName?: string;
}) {
const action: CardCartActionConfig = cartAction || {
saveMode: "cart",
label: "담기",
cancelLabel: "취소",
};
const update = (partial: Partial<CardCartActionConfig>) => {
onUpdate({ ...action, ...partial });
};
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]);
return (
<div className="space-y-3">
{/* 저장 방식 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={saveMode}
onValueChange={(v) => update({ saveMode: v as "cart" | "direct" })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cart" className="text-xs"> </SelectItem>
<SelectItem value="direct" className="text-xs"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{/* 행 식별 키 컬럼 */}
{saveMode === "cart" && (
<div>
<Label className="text-[10px]"> </Label>
<KeyColumnSelect
tableName={tableName}
value={action.keyColumn || "id"}
onValueChange={(v) => update({ keyColumn: v })}
/>
<p className="text-muted-foreground mt-1 text-[10px]">
. 기본값: id (UUID)
</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>
{/* 저장 데이터 정보 (읽기 전용) */}
<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>
</div>
);
}
// ===== 반응형 표시 설정 섹션 =====
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>
);
}
// ===== 필터 기준 섹션 (columnGroups 기반) =====
const FILTER_OPERATORS: { value: FilterOperator; label: string }[] = [
{ value: "=", label: "=" },
{ value: "!=", label: "!=" },
{ value: ">", label: ">" },
{ value: "<", label: "<" },
{ value: ">=", label: ">=" },
{ value: "<=", label: "<=" },
{ value: "like", label: "포함" },
];
function FilterCriteriaSection({
dataSource,
columnGroups,
onUpdate,
}: {
dataSource: CardListDataSource;
columnGroups: ColumnGroup[];
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const filters = dataSource.filters || [];
const addFilter = () => {
const newFilter: CardColumnFilter = { column: "", operator: "=", value: "" };
onUpdate({ filters: [...filters, newFilter] });
};
const updateFilter = (index: number, updated: CardColumnFilter) => {
const next = [...filters];
next[index] = updated;
onUpdate({ filters: next });
};
const deleteFilter = (index: number) => {
const next = filters.filter((_, i) => i !== index);
onUpdate({ filters: next.length > 0 ? next : undefined });
};
return (
<div className="space-y-3">
<p className="text-[10px] text-muted-foreground">
.
</p>
{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">
<div className="flex-1">
<GroupedColumnSelect
columnGroups={columnGroups}
value={filter.column || undefined}
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
placeholder="컬럼 선택"
/>
</div>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, { ...filter, operator: val as FilterOperator })
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_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>
);
}
// ===== 저장 매핑 섹션 (장바구니 -> 대상 테이블) =====
const CART_META_FIELDS = [
{ value: "__cart_quantity", label: "입력 수량" },
{ value: "__cart_package_unit", label: "포장 단위" },
{ value: "__cart_package_entries", label: "포장 내역" },
{ value: "__cart_memo", label: "메모" },
{ value: "__cart_row_key", label: "원본 키" },
];
interface CardDisplayedField {
sourceField: string;
label: string;
badge: string;
}
function SaveMappingSection({
saveMapping,
onUpdate,
cartListMode,
}: {
saveMapping?: CardListSaveMapping;
onUpdate: (mapping: CardListSaveMapping) => void;
cartListMode?: CartListModeConfig;
}) {
const mapping: CardListSaveMapping = saveMapping || { targetTable: "", mappings: [] };
const [tables, setTables] = useState<TableInfo[]>([]);
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [sourceTableName, setSourceTableName] = useState("");
const [cardDisplayedFields, setCardDisplayedFields] = useState<CardDisplayedField[]>([]);
useEffect(() => {
fetchTableList().then(setTables);
}, []);
// 원본 화면에서 테이블 컬럼 + 카드 템플릿 필드 추출
useEffect(() => {
if (!cartListMode?.sourceScreenId) {
setSourceColumns([]);
setSourceTableName("");
setCardDisplayedFields([]);
return;
}
screenApi
.getLayoutPop(cartListMode.sourceScreenId)
.then((layoutJson: any) => {
const componentsMap = layoutJson?.components || {};
const componentList = Object.values(componentsMap) as any[];
const matched = cartListMode.sourceComponentId
? componentList.find((c: any) => c.id === cartListMode.sourceComponentId)
: componentList.find((c: any) => c.type === "pop-card-list");
const tableName = matched?.config?.dataSource?.tableName;
if (tableName) {
setSourceTableName(tableName);
fetchTableColumns(tableName).then(setSourceColumns);
}
// 카드 템플릿에서 표시 중인 필드 추출
const cardTemplate = matched?.config?.cardTemplate;
const inputFieldConfig = matched?.config?.inputField;
const packageConfig = matched?.config?.packageConfig;
const displayed: CardDisplayedField[] = [];
if (cardTemplate?.header?.codeField) {
displayed.push({
sourceField: cardTemplate.header.codeField,
label: cardTemplate.header.codeField,
badge: "헤더",
});
}
if (cardTemplate?.header?.titleField) {
displayed.push({
sourceField: cardTemplate.header.titleField,
label: cardTemplate.header.titleField,
badge: "헤더",
});
}
for (const f of cardTemplate?.body?.fields || []) {
if (f.valueType === "column" && f.columnName) {
displayed.push({
sourceField: f.columnName,
label: f.label || f.columnName,
badge: "본문",
});
} else if (f.valueType === "formula" && f.label) {
const formulaKey = `__formula_${f.id || f.label}`;
displayed.push({
sourceField: formulaKey,
label: f.label,
badge: "수식",
});
}
}
if (inputFieldConfig?.enabled) {
displayed.push({
sourceField: "__cart_quantity",
label: "입력 수량",
badge: "입력",
});
}
if (packageConfig?.enabled) {
displayed.push({
sourceField: "__cart_package_unit",
label: "포장 단위",
badge: "포장",
});
displayed.push({
sourceField: "__cart_package_entries",
label: "포장 내역",
badge: "포장",
});
}
setCardDisplayedFields(displayed);
})
.catch(() => {
setSourceColumns([]);
setSourceTableName("");
setCardDisplayedFields([]);
});
}, [cartListMode?.sourceScreenId, cartListMode?.sourceComponentId]);
useEffect(() => {
if (mapping.targetTable) {
fetchTableColumns(mapping.targetTable).then(setTargetColumns);
} else {
setTargetColumns([]);
}
}, [mapping.targetTable]);
// 카드에 표시된 필드 set (빠른 조회용)
const cardFieldSet = useMemo(
() => new Set(cardDisplayedFields.map((f) => f.sourceField)),
[cardDisplayedFields]
);
const getSourceFieldLabel = (field: string) => {
const cardField = cardDisplayedFields.find((f) => f.sourceField === field);
if (cardField) return cardField.label;
const meta = CART_META_FIELDS.find((f) => f.value === field);
if (meta) return meta.label;
return field;
};
const getFieldBadge = (field: string) => {
const cardField = cardDisplayedFields.find((f) => f.sourceField === field);
return cardField?.badge || null;
};
const isCartMeta = (field: string) => field.startsWith("__cart_");
const getSourceTableDisplayName = () => {
if (!sourceTableName) return "원본 데이터";
const found = tables.find((t) => t.tableName === sourceTableName);
return found?.displayName || sourceTableName;
};
const mappedSourceFields = useMemo(
() => new Set(mapping.mappings.map((m) => m.sourceField)),
[mapping.mappings]
);
// 카드에 표시된 필드가 로드되면 매핑에 누락된 필드를 자동 추가 (매핑 안함으로)
useEffect(() => {
if (!mapping.targetTable || cardDisplayedFields.length === 0) return;
const existing = new Set(mapping.mappings.map((m) => m.sourceField));
const missing = cardDisplayedFields.filter((f) => !existing.has(f.sourceField));
if (missing.length === 0) return;
onUpdate({
...mapping,
mappings: [
...mapping.mappings,
...missing.map((f) => ({ sourceField: f.sourceField, targetColumn: "" })),
],
});
}, [cardDisplayedFields]); // eslint-disable-line react-hooks/exhaustive-deps
// 카드에 표시된 필드 중 아직 매핑되지 않은 것
const unmappedCardFields = useMemo(
() => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)),
[cardDisplayedFields, mappedSourceFields]
);
// 카드에 없고 매핑도 안 된 원본 컬럼
const availableExtraSourceFields = useMemo(
() => sourceColumns.filter((col) => !cardFieldSet.has(col.name) && !mappedSourceFields.has(col.name)),
[sourceColumns, cardFieldSet, mappedSourceFields]
);
// 카드에 없고 매핑도 안 된 장바구니 메타
const availableExtraCartFields = useMemo(
() => CART_META_FIELDS.filter((f) => !cardFieldSet.has(f.value) && !mappedSourceFields.has(f.value)),
[cardFieldSet, mappedSourceFields]
);
// 대상 테이블 선택 -> 카드 표시 필드 전체 자동 매핑
const updateTargetTable = (targetTable: string) => {
fetchTableColumns(targetTable).then((targetCols) => {
setTargetColumns(targetCols);
const targetNameSet = new Set(targetCols.map((c) => c.name));
const autoMappings: CardListSaveMappingEntry[] = [];
for (const field of cardDisplayedFields) {
autoMappings.push({
sourceField: field.sourceField,
targetColumn: targetNameSet.has(field.sourceField) ? field.sourceField : "",
});
}
onUpdate({ targetTable, mappings: autoMappings });
});
};
const addFieldMapping = (sourceField: string) => {
const matched = targetColumns.find((tc) => tc.name === sourceField);
onUpdate({
...mapping,
mappings: [
...mapping.mappings,
{ sourceField, targetColumn: matched?.name || "" },
],
});
};
const updateEntry = (index: number, updated: CardListSaveMappingEntry) => {
const next = [...mapping.mappings];
next[index] = updated;
onUpdate({ ...mapping, mappings: next });
};
const deleteEntry = (index: number) => {
const next = mapping.mappings.filter((_, i) => i !== index);
onUpdate({ ...mapping, mappings: next });
};
const autoMatchedCount = mapping.mappings.filter((m) => m.targetColumn).length;
// 매핑 행 렌더링 (공용)
const renderMappingRow = (entry: CardListSaveMappingEntry, index: number) => {
const badge = getFieldBadge(entry.sourceField);
return (
<div
key={`${entry.sourceField}-${index}`}
className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5"
>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-1">
<span className="truncate text-xs font-medium">
{getSourceFieldLabel(entry.sourceField)}
</span>
{badge && (
<span className="shrink-0 rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary">
{badge}
</span>
)}
</div>
{isCartMeta(entry.sourceField) ? (
!badge && <span className="text-[9px] text-muted-foreground"></span>
) : entry.sourceField.startsWith("__formula_") ? null : (
<span className="truncate text-[9px] text-muted-foreground">
{entry.sourceField}
</span>
)}
</div>
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<div className="flex-1">
<Select
value={entry.targetColumn || "__none__"}
onValueChange={(val) =>
updateEntry(index, {
...entry,
targetColumn: val === "__none__" ? "" : val,
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="대상 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{targetColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-destructive"
onClick={() => deleteEntry(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
};
// 매핑 목록을 카드필드 / 추가필드로 분리
const cardMappings: { entry: CardListSaveMappingEntry; index: number }[] = [];
const extraMappings: { entry: CardListSaveMappingEntry; index: number }[] = [];
mapping.mappings.forEach((entry, index) => {
if (cardFieldSet.has(entry.sourceField)) {
cardMappings.push({ entry, index });
} else {
extraMappings.push({ entry, index });
}
});
return (
<div className="space-y-3">
<p className="text-[10px] text-muted-foreground">
.
</p>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<TableCombobox
tables={tables}
value={mapping.targetTable}
onSelect={updateTargetTable}
/>
</div>
{!mapping.targetTable ? (
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<>
{/* 자동 매핑 안내 */}
{autoMatchedCount > 0 && (
<div className="flex items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-1.5">
<Check className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="text-[10px] text-primary">
{autoMatchedCount}
</span>
</div>
)}
{/* --- 카드에 표시된 필드 --- */}
{(cardMappings.length > 0 || unmappedCardFields.length > 0) && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<div className="h-px flex-1 bg-border" />
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
</span>
<div className="h-px flex-1 bg-border" />
</div>
{cardMappings.map(({ entry, index }) => renderMappingRow(entry, index))}
{/* 카드 필드 중 매핑 안 된 것 -> 칩으로 추가 */}
{unmappedCardFields.length > 0 && (
<div className="flex flex-wrap gap-1">
{unmappedCardFields.map((f) => (
<button
key={f.sourceField}
type="button"
onClick={() => addFieldMapping(f.sourceField)}
className="inline-flex items-center gap-1 rounded-md border border-primary/30 bg-primary/5 px-2 py-1 text-[10px] text-primary transition-colors hover:bg-primary/10"
>
<Plus className="h-2.5 w-2.5" />
{f.label}
<span className="rounded bg-primary/10 px-0.5 text-[8px]">{f.badge}</span>
</button>
))}
</div>
)}
</div>
)}
{/* --- 추가로 저장할 필드 --- */}
{(extraMappings.length > 0 || availableExtraSourceFields.length > 0 || availableExtraCartFields.length > 0) && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<div className="h-px flex-1 bg-border" />
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
</span>
<div className="h-px flex-1 bg-border" />
</div>
{extraMappings.map(({ entry, index }) => renderMappingRow(entry, index))}
{/* 추가 가능한 필드 칩 */}
{(availableExtraSourceFields.length > 0 || availableExtraCartFields.length > 0) && (
<div className="flex flex-wrap gap-1">
{availableExtraSourceFields.map((col) => (
<button
key={col.name}
type="button"
onClick={() => addFieldMapping(col.name)}
className="inline-flex items-center gap-1 rounded-md border border-input px-2 py-1 text-[10px] transition-colors hover:bg-accent"
>
<Plus className="h-2.5 w-2.5" />
{col.name}
</button>
))}
{availableExtraCartFields.map((f) => (
<button
key={f.value}
type="button"
onClick={() => addFieldMapping(f.value)}
className="inline-flex items-center gap-1 rounded-md border border-dashed border-input px-2 py-1 text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Plus className="h-2.5 w-2.5" />
{f.label}
</button>
))}
</div>
)}
</div>
)}
</>
)}
</div>
);
}