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

1875 lines
57 KiB
TypeScript
Raw Normal View History

"use client";
/**
* pop-card-list
*
* 3 :
* [] -
* [ 릿] - // +
* [ ] - ///
*/
import React, { useState, useEffect, useMemo } from "react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database } 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,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
PopCardListConfig,
CardListDataSource,
CardTemplateConfig,
CardHeaderConfig,
CardImageConfig,
CardBodyConfig,
CardFieldBinding,
CardColumnJoin,
CardColumnFilter,
CardScrollDirection,
FilterOperator,
CardInputFieldConfig,
CardCalculatedFieldConfig,
CardCartActionConfig,
} from "../types";
import {
CARD_SCROLL_DIRECTION_LABELS,
DEFAULT_CARD_IMAGE,
} from "../types";
import {
fetchTableList,
fetchTableColumns,
type TableInfo,
type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher";
// ===== Props =====
interface ConfigPanelProps {
config: PopCardListConfig | undefined;
onUpdate: (config: PopCardListConfig) => void;
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) {
// 3탭 구조: 기본 설정 (테이블+레이아웃) → 데이터 소스 → 카드 템플릿
const [activeTab, setActiveTab] = useState<"basic" | "template" | "dataSource">(
"basic"
);
// config가 없으면 기본값 사용
const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
// config 업데이트 헬퍼
const updateConfig = (partial: Partial<PopCardListConfig>) => {
onUpdate({ ...cfg, ...partial });
};
// 테이블이 선택되었는지 확인
const hasTable = !!cfg.dataSource?.tableName;
return (
<div className="flex h-full flex-col">
{/* 탭 헤더 - 3탭 구조 */}
<div className="flex border-b">
<button
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
activeTab === "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 === "dataSource"
? "border-b-2 border-primary text-primary"
: hasTable
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground/50 cursor-not-allowed"
}`}
onClick={() => hasTable && setActiveTab("dataSource")}
disabled={!hasTable}
>
</button>
<button
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
activeTab === "template"
? "border-b-2 border-primary text-primary"
: hasTable
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground/50 cursor-not-allowed"
}`}
onClick={() => hasTable && setActiveTab("template")}
disabled={!hasTable}
>
릿
</button>
</div>
{/* 탭 내용 */}
<div className="flex-1 overflow-y-auto p-3">
{activeTab === "basic" && (
<BasicSettingsTab
config={cfg}
onUpdate={updateConfig}
currentMode={currentMode}
currentColSpan={currentColSpan}
/>
)}
{activeTab === "dataSource" && (
<DataSourceTab config={cfg} onUpdate={updateConfig} />
)}
{activeTab === "template" && (
<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[]>([]);
// 테이블 목록 로드
useEffect(() => {
fetchTableList().then(setTables);
}, []);
// 모드별 추천값 계산
const recommendation = useMemo(() => {
if (!currentMode) return null;
const columns = GRID_BREAKPOINTS[currentMode].columns;
if (columns >= 8) return { rows: 3, cols: 2 };
if (columns >= 6) return { rows: 3, cols: 1 };
return { rows: 2, cols: 1 };
}, [currentMode]);
// 열 최대값: colSpan 기반 제한
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
return (
<div className="space-y-4">
{/* 테이블 선택 섹션 */}
<CollapsibleSection title="테이블 선택" defaultOpen>
<div className="space-y-3">
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={dataSource.tableName || ""}
onValueChange={(val) => {
onUpdate({
dataSource: {
tableName: val,
joins: undefined,
filters: undefined,
sort: undefined,
limit: undefined,
},
cardTemplate: DEFAULT_TEMPLATE,
});
}}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{dataSource.tableName && (
<div className="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>
{/* 레이아웃 설정 섹션 */}
<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>
{/* 그리드 배치 설정 (행 x 열) */}
<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>
);
}
// ===== 데이터 소스 탭 =====
function DataSourceTab({
config,
onUpdate,
}: {
config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void;
}) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
// 테이블 목록 로드
useEffect(() => {
fetchTableList().then(setTables);
}, []);
// 테이블 선택 시 컬럼 목록 로드
useEffect(() => {
if (dataSource.tableName) {
fetchTableColumns(dataSource.tableName).then(setColumns);
} else {
setColumns([]);
}
}, [dataSource.tableName]);
const updateDataSource = (partial: Partial<CardListDataSource>) => {
onUpdate({
dataSource: { ...dataSource, ...partial },
});
};
// 테이블이 선택되지 않은 경우
if (!dataSource.tableName) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Database className="mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* 현재 선택된 테이블 표시 */}
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium">{dataSource.tableName}</span>
</div>
{/* 조인 설정 */}
<CollapsibleSection
title="조인 설정"
badge={
dataSource.joins && dataSource.joins.length > 0
? `${dataSource.joins.length}`
: "없음"
}
>
<JoinSettingsSection
dataSource={dataSource}
tables={tables}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
{/* 필터 설정 */}
<CollapsibleSection
title="필터 조건"
badge={
dataSource.filters && dataSource.filters.length > 0
? `${dataSource.filters.length}`
: "없음"
}
>
<FilterSettingsSection
dataSource={dataSource}
columns={columns}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
{/* 정렬 설정 */}
<CollapsibleSection
title="정렬 기준"
badge={dataSource.sort ? "설정됨" : "없음"}
>
<SortSettingsSection
dataSource={dataSource}
columns={columns}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
{/* 표시 개수 */}
<CollapsibleSection
title="표시 개수"
badge={
dataSource.limit?.mode === "limited"
? `${dataSource.limit.count}`
: "전체"
}
>
<LimitSettingsSection
dataSource={dataSource}
onUpdate={updateDataSource}
/>
</CollapsibleSection>
</div>
);
}
// ===== 카드 템플릿 탭 =====
function CardTemplateTab({
config,
onUpdate,
}: {
config: PopCardListConfig;
onUpdate: (partial: Partial<PopCardListConfig>) => void;
}) {
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
const template = config.cardTemplate || DEFAULT_TEMPLATE;
const [columns, setColumns] = useState<ColumnInfo[]>([]);
// 테이블 컬럼 로드
useEffect(() => {
if (dataSource.tableName) {
fetchTableColumns(dataSource.tableName).then(setColumns);
} else {
setColumns([]);
}
}, [dataSource.tableName]);
const updateTemplate = (partial: Partial<CardTemplateConfig>) => {
onUpdate({
cardTemplate: { ...template, ...partial },
});
};
// 테이블 미선택 시 안내
if (!dataSource.tableName) {
return (
<div className="flex h-40 items-center justify-center text-center">
<div className="text-muted-foreground">
<p className="text-sm"> </p>
<p className="mt-1 text-xs"> </p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 헤더 설정 */}
<CollapsibleSection title="헤더 설정" defaultOpen>
<HeaderSettingsSection
header={template.header || DEFAULT_HEADER}
columns={columns}
onUpdate={(header) => updateTemplate({ header })}
/>
</CollapsibleSection>
{/* 이미지 설정 */}
<CollapsibleSection title="이미지 설정" defaultOpen>
<ImageSettingsSection
image={template.image || DEFAULT_IMAGE}
columns={columns}
onUpdate={(image) => updateTemplate({ image })}
/>
</CollapsibleSection>
{/* 본문 필드 */}
<CollapsibleSection
title="본문 필드"
badge={`${template.body?.fields?.length || 0}`}
defaultOpen
>
<BodyFieldsSection
body={template.body || DEFAULT_BODY}
columns={columns}
onUpdate={(body) => updateTemplate({ body })}
/>
</CollapsibleSection>
{/* 입력 필드 설정 */}
<CollapsibleSection title="입력 필드" defaultOpen={false}>
<InputFieldSettingsSection
inputField={config.inputField}
columns={columns}
onUpdate={(inputField) => onUpdate({ inputField })}
/>
</CollapsibleSection>
{/* 계산 필드 설정 */}
<CollapsibleSection title="계산 필드" defaultOpen={false}>
<CalculatedFieldSettingsSection
calculatedField={config.calculatedField}
columns={columns}
onUpdate={(calculatedField) => onUpdate({ calculatedField })}
/>
</CollapsibleSection>
{/* 담기 버튼 설정 */}
<CollapsibleSection title="담기 버튼 (pop-icon)" defaultOpen={false}>
<CartActionSettingsSection
cartAction={config.cartAction}
onUpdate={(cartAction) => onUpdate({ cartAction })}
/>
</CollapsibleSection>
</div>
);
}
// ===== 접기/펴기 섹션 컴포넌트 =====
function CollapsibleSection({
title,
badge,
defaultOpen = false,
children,
}: {
title: string;
badge?: string;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="rounded-md border">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen(!open)}
>
<div className="flex items-center gap-2">
{open ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-xs font-medium">{title}</span>
</div>
{badge && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{badge}
</span>
)}
</button>
{open && <div className="border-t px-3 py-3">{children}</div>}
</div>
);
}
// ===== 헤더 설정 섹션 =====
function HeaderSettingsSection({
header,
columns,
onUpdate,
}: {
header: CardHeaderConfig;
columns: ColumnInfo[];
onUpdate: (header: CardHeaderConfig) => void;
}) {
return (
<div className="space-y-3">
{/* 코드 필드 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={header.codeField || "__none__"}
onValueChange={(val) =>
onUpdate({ ...header, codeField: val === "__none__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
(: ITEM032)
</p>
</div>
{/* 제목 필드 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={header.titleField || "__none__"}
onValueChange={(val) =>
onUpdate({ ...header, titleField: val === "__none__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
(: 너트 M10)
</p>
</div>
</div>
);
}
// ===== 이미지 설정 섹션 =====
function ImageSettingsSection({
image,
columns,
onUpdate,
}: {
image: CardImageConfig;
columns: ColumnInfo[];
onUpdate: (image: CardImageConfig) => void;
}) {
return (
<div className="space-y-3">
{/* 이미지 사용 여부 */}
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={image.enabled}
onCheckedChange={(checked) => onUpdate({ ...image, enabled: checked })}
/>
</div>
{image.enabled && (
<>
{/* 기본 이미지 미리보기 */}
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-2 text-[10px] text-muted-foreground">
</div>
<div className="flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-md border bg-card">
<img
src={image.defaultImage || DEFAULT_CARD_IMAGE}
alt="기본 이미지"
className="h-12 w-12 object-contain"
/>
</div>
</div>
</div>
{/* 기본 이미지 URL */}
<div>
<Label className="text-[10px]"> URL</Label>
<Input
value={image.defaultImage || ""}
onChange={(e) =>
onUpdate({
...image,
defaultImage: e.target.value || DEFAULT_CARD_IMAGE,
})
}
placeholder="이미지 URL 입력"
className="mt-1 h-7 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
{/* 이미지 컬럼 선택 */}
<div>
<Label className="text-[10px]"> URL ()</Label>
<Select
value={image.imageColumn || "__none__"}
onValueChange={(val) =>
onUpdate({ ...image, imageColumn: val === "__none__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> ( )</SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
DB에서 URL을 . URL이
</p>
</div>
</>
)}
</div>
);
}
// ===== 본문 필드 섹션 =====
function BodyFieldsSection({
body,
columns,
onUpdate,
}: {
body: CardBodyConfig;
columns: ColumnInfo[];
onUpdate: (body: CardBodyConfig) => void;
}) {
const fields = body.fields || [];
// 필드 추가
const addField = () => {
const newField: CardFieldBinding = {
id: `field-${Date.now()}`,
columnName: "",
label: "",
textColor: undefined,
};
onUpdate({ fields: [...fields, newField] });
};
// 필드 업데이트
const updateField = (index: number, updated: CardFieldBinding) => {
const newFields = [...fields];
newFields[index] = updated;
onUpdate({ fields: newFields });
};
// 필드 삭제
const deleteField = (index: number) => {
const newFields = fields.filter((_, i) => i !== index);
onUpdate({ fields: newFields });
};
// 필드 순서 이동
const moveField = (index: number, direction: "up" | "down") => {
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= fields.length) return;
const newFields = [...fields];
[newFields[index], newFields[newIndex]] = [
newFields[newIndex],
newFields[index],
];
onUpdate({ fields: newFields });
};
return (
<div className="space-y-3">
{/* 필드 목록 */}
{fields.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{fields.map((field, index) => (
<FieldEditor
key={field.id}
field={field}
index={index}
columns={columns}
totalCount={fields.length}
onUpdate={(updated) => updateField(index, updated)}
onDelete={() => deleteField(index)}
onMove={(dir) => moveField(index, dir)}
/>
))}
</div>
)}
{/* 필드 추가 버튼 */}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addField}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ===== 필드 편집기 =====
function FieldEditor({
field,
index,
columns,
totalCount,
onUpdate,
onDelete,
onMove,
}: {
field: CardFieldBinding;
index: number;
columns: ColumnInfo[];
totalCount: number;
onUpdate: (field: CardFieldBinding) => void;
onDelete: () => void;
onMove: (direction: "up" | "down") => void;
}) {
return (
<div className="rounded-md border bg-card p-2">
<div className="flex items-start gap-2">
{/* 순서 이동 버튼 */}
<div className="flex flex-col gap-0.5 pt-1">
<button
type="button"
className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
onClick={() => onMove("up")}
disabled={index === 0}
>
<ChevronDown className="h-3 w-3 rotate-180" />
</button>
<button
type="button"
className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
onClick={() => onMove("down")}
disabled={index === totalCount - 1}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
{/* 필드 설정 */}
<div className="flex-1 space-y-2">
<div className="flex gap-2">
{/* 라벨 */}
<div className="flex-1">
<Label className="text-[10px]"></Label>
<Input
value={field.label}
onChange={(e) => onUpdate({ ...field, label: e.target.value })}
placeholder="예: 발주일"
className="mt-1 h-7 text-xs"
/>
</div>
{/* 컬럼 */}
<div className="flex-1">
<Label className="text-[10px]"></Label>
<Select
value={field.columnName || "__placeholder__"}
onValueChange={(val) =>
onUpdate({ ...field, columnName: val === "__placeholder__" ? "" : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__placeholder__" disabled>
</SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 텍스트 색상 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={field.textColor || "__default__"}
onValueChange={(val) =>
onUpdate({ ...field, textColor: val === "__default__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="기본" />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex items-center gap-2">
{opt.value !== "__default__" && (
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: opt.value }}
/>
)}
{opt.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
}
// ===== 입력 필드 설정 섹션 =====
function InputFieldSettingsSection({
inputField,
columns,
onUpdate,
}: {
inputField?: CardInputFieldConfig;
columns: ColumnInfo[];
onUpdate: (inputField: CardInputFieldConfig) => void;
}) {
const field = inputField || {
enabled: false,
label: "발주 수량",
unit: "EA",
defaultValue: 0,
min: 0,
max: 999999,
step: 1,
};
const updateField = (partial: Partial<CardInputFieldConfig>) => {
onUpdate({ ...field, ...partial });
};
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.label || ""}
onChange={(e) => updateField({ label: e.target.value })}
className="mt-1 h-7 text-xs"
placeholder="발주 수량"
/>
</div>
{/* 단위 */}
<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>
<Input
type="number"
value={field.defaultValue || 0}
onChange={(e) => updateField({ defaultValue: parseInt(e.target.value, 10) || 0 })}
className="mt-1 h-7 text-xs"
placeholder="0"
/>
</div>
{/* 최소/최대값 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
type="number"
value={field.min || 0}
onChange={(e) => updateField({ min: parseInt(e.target.value, 10) || 0 })}
className="mt-1 h-7 text-xs"
placeholder="0"
/>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
type="number"
value={field.max || 999999}
onChange={(e) => updateField({ max: parseInt(e.target.value, 10) || 999999 })}
className="mt-1 h-7 text-xs"
placeholder="999999"
/>
</div>
</div>
{/* 최대값 컬럼 */}
<div>
<Label className="text-[10px] text-muted-foreground">
()
</Label>
<Select
value={field.maxColumn || "__none__"}
onValueChange={(val) =>
updateField({ maxColumn: val === "__none__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> ( )</SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[9px] text-muted-foreground">
(: unreceived_qty)
</p>
</div>
{/* 저장 컬럼 (선택사항) */}
<div>
<Label className="text-[10px] text-muted-foreground">
()
</Label>
<Select
value={field.columnName || "__none__"}
onValueChange={(val) =>
updateField({ columnName: val === "__none__" ? undefined : val })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[9px] text-muted-foreground">
DB ( )
</p>
</div>
</>
)}
</div>
);
}
// ===== 계산 필드 설정 섹션 =====
function CalculatedFieldSettingsSection({
calculatedField,
columns,
onUpdate,
}: {
calculatedField?: CardCalculatedFieldConfig;
columns: ColumnInfo[];
onUpdate: (calculatedField: CardCalculatedFieldConfig) => void;
}) {
const field = calculatedField || {
enabled: false,
label: "미입고",
formula: "",
sourceColumns: [],
unit: "EA",
};
const updateField = (partial: Partial<CardCalculatedFieldConfig>) => {
onUpdate({ ...field, ...partial });
};
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.label || ""}
onChange={(e) => updateField({ label: e.target.value })}
className="mt-1 h-7 text-xs"
placeholder="미입고"
/>
</div>
{/* 계산식 */}
<div>
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={field.formula || ""}
onChange={(e) => updateField({ formula: e.target.value })}
className="mt-1 h-7 text-xs font-mono"
placeholder="$input - received_qty"
/>
<p className="mt-1 text-[9px] text-muted-foreground">
가능: 컬럼명, $input (), +, -, *, /
</p>
</div>
{/* 단위 */}
<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>
<div className="mt-1 max-h-24 overflow-y-auto rounded border bg-muted/30 p-2">
<div className="flex flex-wrap gap-1">
{columns.map((col) => (
<span
key={col.name}
className="cursor-pointer rounded bg-muted px-1.5 py-0.5 text-[9px] hover:bg-primary/20"
onClick={() => {
// 클릭 시 계산식에 컬럼명 추가
const currentFormula = field.formula || "";
updateField({ formula: currentFormula + col.name });
}}
>
{col.name}
</span>
))}
</div>
</div>
<p className="mt-1 text-[9px] text-muted-foreground">
</p>
</div>
</>
)}
</div>
);
}
// ===== 조인 설정 섹션 =====
function JoinSettingsSection({
dataSource,
tables,
onUpdate,
}: {
dataSource: CardListDataSource;
tables: TableInfo[];
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const joins = dataSource.joins || [];
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [targetColumnsMap, setTargetColumnsMap] = useState<
Record<string, ColumnInfo[]>
>({});
// 소스 테이블 컬럼 로드
useEffect(() => {
if (dataSource.tableName) {
fetchTableColumns(dataSource.tableName).then(setSourceColumns);
}
}, [dataSource.tableName]);
// 조인 추가
const addJoin = () => {
const newJoin: CardColumnJoin = {
targetTable: "",
joinType: "LEFT",
sourceColumn: "",
targetColumn: "",
};
onUpdate({ joins: [...joins, newJoin] });
};
// 조인 업데이트
const updateJoin = (index: number, updated: CardColumnJoin) => {
const newJoins = [...joins];
newJoins[index] = updated;
onUpdate({ joins: newJoins });
// 대상 테이블 컬럼 로드
if (updated.targetTable && !targetColumnsMap[updated.targetTable]) {
fetchTableColumns(updated.targetTable).then((cols) => {
setTargetColumnsMap((prev) => ({
...prev,
[updated.targetTable]: cols,
}));
});
}
};
// 조인 삭제
const deleteJoin = (index: number) => {
const newJoins = joins.filter((_, i) => i !== index);
onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined });
};
return (
<div className="space-y-3">
{joins.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{joins.map((join, index) => (
<div key={index} className="rounded-md border bg-card p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium">
{index + 1}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
onClick={() => deleteJoin(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 조인 타입 */}
<Select
value={join.joinType}
onValueChange={(val) =>
updateJoin(index, {
...join,
joinType: val as "LEFT" | "INNER" | "RIGHT",
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEFT">LEFT JOIN</SelectItem>
<SelectItem value="INNER">INNER JOIN</SelectItem>
<SelectItem value="RIGHT">RIGHT JOIN</SelectItem>
</SelectContent>
</Select>
{/* 대상 테이블 */}
<Select
value={join.targetTable || ""}
onValueChange={(val) =>
updateJoin(index, {
...join,
targetTable: val,
targetColumn: "",
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="대상 테이블" />
</SelectTrigger>
<SelectContent>
{tables
.filter((t) => t.tableName !== dataSource.tableName)
.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
{/* ON 조건 */}
{join.targetTable && (
<div className="flex items-center gap-1 rounded bg-muted/50 p-1.5">
<Select
value={join.sourceColumn || ""}
onValueChange={(val) =>
updateJoin(index, { ...join, sourceColumn: val })
}
>
<SelectTrigger className="h-6 text-[10px] flex-1">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
{sourceColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">=</span>
<Select
value={join.targetColumn || ""}
onValueChange={(val) =>
updateJoin(index, { ...join, targetColumn: val })
}
>
<SelectTrigger className="h-6 text-[10px] flex-1">
<SelectValue placeholder="대상 컬럼" />
</SelectTrigger>
<SelectContent>
{(targetColumnsMap[join.targetTable] || []).map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addJoin}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ===== 필터 설정 섹션 =====
function FilterSettingsSection({
dataSource,
columns,
onUpdate,
}: {
dataSource: CardListDataSource;
columns: ColumnInfo[];
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const filters = dataSource.filters || [];
const operators: { value: FilterOperator; label: string }[] = [
{ value: "=", label: "=" },
{ value: "!=", label: "!=" },
{ value: ">", label: ">" },
{ value: ">=", label: ">=" },
{ value: "<", label: "<" },
{ value: "<=", label: "<=" },
{ value: "like", label: "LIKE" },
];
// 필터 추가
const addFilter = () => {
const newFilter: CardColumnFilter = {
column: "",
operator: "=",
value: "",
};
onUpdate({ filters: [...filters, newFilter] });
};
// 필터 업데이트
const updateFilter = (index: number, updated: CardColumnFilter) => {
const newFilters = [...filters];
newFilters[index] = updated;
onUpdate({ filters: newFilters });
};
// 필터 삭제
const deleteFilter = (index: number) => {
const newFilters = filters.filter((_, i) => i !== index);
onUpdate({ filters: newFilters.length > 0 ? newFilters : undefined });
};
return (
<div className="space-y-3">
{filters.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{filters.map((filter, index) => (
<div
key={index}
className="flex items-center gap-1 rounded-md border bg-card p-1.5"
>
<Select
value={filter.column || ""}
onValueChange={(val) =>
updateFilter(index, { ...filter, column: val })
}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.operator}
onValueChange={(val) =>
updateFilter(index, {
...filter,
operator: val as FilterOperator,
})
}
>
<SelectTrigger className="h-7 w-16 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) =>
updateFilter(index, { ...filter, value: e.target.value })
}
placeholder="값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => deleteFilter(index)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={addFilter}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
);
}
// ===== 정렬 설정 섹션 =====
function SortSettingsSection({
dataSource,
columns,
onUpdate,
}: {
dataSource: CardListDataSource;
columns: ColumnInfo[];
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const sort = dataSource.sort;
return (
<div className="space-y-3">
{/* 정렬 사용 여부 */}
<div className="flex gap-2">
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
!sort ? "border-primary bg-primary/10" : "border-input"
}`}
onClick={() => onUpdate({ sort: undefined })}
>
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
sort ? "border-primary bg-primary/10" : "border-input"
}`}
onClick={() =>
onUpdate({ sort: { column: "", direction: "asc" } })
}
>
</button>
</div>
{sort && (
<div className="space-y-2">
{/* 정렬 컬럼 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={sort.column || ""}
onValueChange={(val) =>
onUpdate({ sort: { ...sort, column: val } })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 정렬 방향 */}
<div>
<Label className="text-[10px]"> </Label>
<div className="mt-1 flex gap-2">
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
sort.direction === "asc"
? "border-primary bg-primary/10"
: "border-input"
}`}
onClick={() => onUpdate({ sort: { ...sort, direction: "asc" } })}
>
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
sort.direction === "desc"
? "border-primary bg-primary/10"
: "border-input"
}`}
onClick={() =>
onUpdate({ sort: { ...sort, direction: "desc" } })
}
>
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ===== 표시 개수 설정 섹션 =====
function LimitSettingsSection({
dataSource,
onUpdate,
}: {
dataSource: CardListDataSource;
onUpdate: (partial: Partial<CardListDataSource>) => void;
}) {
const limit = dataSource.limit || { mode: "all" as const };
const isLimited = limit.mode === "limited";
return (
<div className="space-y-3">
{/* 모드 선택 */}
<div className="flex gap-2">
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
!isLimited ? "border-primary bg-primary/10" : "border-input"
}`}
onClick={() => onUpdate({ limit: { mode: "all" } })}
>
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-1.5 text-xs ${
isLimited ? "border-primary bg-primary/10" : "border-input"
}`}
onClick={() => onUpdate({ limit: { mode: "limited", count: 10 } })}
>
</button>
</div>
{isLimited && (
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
value={limit.count || 10}
onChange={(e) =>
onUpdate({
limit: {
mode: "limited",
count: parseInt(e.target.value, 10) || 10,
},
})
}
className="mt-1 h-7 text-xs"
/>
</div>
)}
</div>
);
}
// ===== 담기 버튼 설정 섹션 =====
function CartActionSettingsSection({
cartAction,
onUpdate,
}: {
cartAction?: CardCartActionConfig;
onUpdate: (cartAction: CardCartActionConfig) => void;
}) {
const action: CardCartActionConfig = cartAction || {
navigateMode: "none",
iconType: "lucide",
iconValue: "ShoppingCart",
label: "담기",
cancelLabel: "취소",
};
const update = (partial: Partial<CardCartActionConfig>) => {
onUpdate({ ...action, ...partial });
};
return (
<div className="space-y-3">
{/* 네비게이션 모드 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={action.navigateMode}
onValueChange={(v) =>
update({ navigateMode: v as "none" | "screen" })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> ()</SelectItem>
<SelectItem value="screen">POP </SelectItem>
</SelectContent>
</Select>
</div>
{/* 대상 화면 ID (screen 모드일 때만) */}
{action.navigateMode === "screen" && (
<div>
<Label className="text-[10px]"> ID</Label>
<Input
value={action.targetScreenId || ""}
onChange={(e) => update({ targetScreenId: e.target.value })}
placeholder="예: 15"
className="mt-1 h-7 text-xs"
/>
<p className="text-muted-foreground mt-1 text-[10px]">
POP screenId
</p>
</div>
)}
{/* 아이콘 타입 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={action.iconType || "lucide"}
onValueChange={(v) =>
update({ iconType: v as "lucide" | "emoji" })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="lucide">Lucide </SelectItem>
<SelectItem value="emoji"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 아이콘 값 */}
<div>
<Label className="text-[10px]">
{action.iconType === "emoji" ? "이모지" : "Lucide 아이콘명"}
</Label>
<Input
value={action.iconValue || ""}
onChange={(e) => update({ iconValue: e.target.value })}
placeholder={
action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart"
}
className="mt-1 h-7 text-xs"
/>
{action.iconType === "lucide" && (
<p className="text-muted-foreground mt-1 text-[10px]">
PascalCase로 (ShoppingCart, Package, Truck )
</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>
);
}