2026-02-27 12:48:33 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* pop-field 설정 패널
|
|
|
|
|
*
|
|
|
|
|
* 구조:
|
|
|
|
|
* - [레이아웃 탭] 섹션 목록 (추가/삭제/이동) + 필드 편집
|
|
|
|
|
* - [저장 탭] 저장 테이블 / 필드-컬럼 매핑 / 읽기 데이터 소스
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
|
|
|
import {
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Plus,
|
|
|
|
|
Trash2,
|
|
|
|
|
GripVertical,
|
|
|
|
|
Check,
|
|
|
|
|
ChevronsUpDown,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import {
|
|
|
|
|
Command,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandInput,
|
|
|
|
|
CommandItem,
|
|
|
|
|
CommandList,
|
|
|
|
|
} from "@/components/ui/command";
|
|
|
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import type {
|
|
|
|
|
PopFieldConfig,
|
|
|
|
|
PopFieldSection,
|
|
|
|
|
PopFieldItem,
|
|
|
|
|
FieldInputType,
|
|
|
|
|
FieldSectionStyle,
|
|
|
|
|
FieldSectionAppearance,
|
|
|
|
|
FieldSelectSource,
|
|
|
|
|
AutoNumberConfig,
|
|
|
|
|
FieldValueSource,
|
|
|
|
|
PopFieldSaveMapping,
|
|
|
|
|
PopFieldSaveConfig,
|
|
|
|
|
PopFieldReadMapping,
|
|
|
|
|
PopFieldHiddenMapping,
|
|
|
|
|
PopFieldAutoGenMapping,
|
|
|
|
|
HiddenValueSource,
|
|
|
|
|
} from "./types";
|
|
|
|
|
import {
|
|
|
|
|
DEFAULT_FIELD_CONFIG,
|
|
|
|
|
DEFAULT_SECTION_APPEARANCES,
|
|
|
|
|
FIELD_INPUT_TYPE_LABELS,
|
|
|
|
|
FIELD_SECTION_STYLE_LABELS,
|
|
|
|
|
} from "./types";
|
|
|
|
|
import {
|
|
|
|
|
fetchTableList,
|
|
|
|
|
fetchTableColumns,
|
|
|
|
|
type TableInfo,
|
|
|
|
|
type ColumnInfo,
|
|
|
|
|
} from "../pop-dashboard/utils/dataFetcher";
|
|
|
|
|
import { dataApi } from "@/lib/api/data";
|
2026-03-04 19:12:22 +09:00
|
|
|
import { getAvailableNumberingRulesForScreen, getNumberingRules } from "@/lib/api/numberingRule";
|
2026-02-27 12:48:33 +09:00
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// Props
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
interface PopFieldConfigPanelProps {
|
|
|
|
|
config: PopFieldConfig | undefined;
|
|
|
|
|
onUpdate: (config: PopFieldConfig) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// 메인 설정 패널
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
export function PopFieldConfigPanel({
|
|
|
|
|
config,
|
|
|
|
|
onUpdate: onConfigChange,
|
|
|
|
|
}: PopFieldConfigPanelProps) {
|
|
|
|
|
const cfg: PopFieldConfig = {
|
|
|
|
|
...DEFAULT_FIELD_CONFIG,
|
|
|
|
|
...config,
|
|
|
|
|
sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections,
|
|
|
|
|
};
|
|
|
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
|
|
|
const [saveTableOpen, setSaveTableOpen] = useState(false);
|
|
|
|
|
const [readTableOpen, setReadTableOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
const saveTableName = cfg.saveConfig?.tableName ?? cfg.targetTable ?? "";
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchTableList().then(setTables);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const updateConfig = useCallback(
|
|
|
|
|
(partial: Partial<PopFieldConfig>) => {
|
|
|
|
|
onConfigChange({ ...cfg, ...partial });
|
|
|
|
|
},
|
|
|
|
|
[cfg, onConfigChange]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const updateSection = useCallback(
|
|
|
|
|
(sectionId: string, partial: Partial<PopFieldSection>) => {
|
|
|
|
|
const sections = cfg.sections.map((s) =>
|
|
|
|
|
s.id === sectionId ? { ...s, ...partial } : s
|
|
|
|
|
);
|
|
|
|
|
updateConfig({ sections });
|
|
|
|
|
},
|
|
|
|
|
[cfg.sections, updateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const addSection = useCallback(() => {
|
|
|
|
|
const newId = `section_${Date.now()}`;
|
|
|
|
|
const newSection: PopFieldSection = {
|
|
|
|
|
id: newId,
|
|
|
|
|
style: "input",
|
|
|
|
|
columns: "auto",
|
|
|
|
|
showLabels: true,
|
|
|
|
|
fields: [],
|
|
|
|
|
};
|
|
|
|
|
updateConfig({ sections: [...cfg.sections, newSection] });
|
|
|
|
|
}, [cfg.sections, updateConfig]);
|
|
|
|
|
|
|
|
|
|
const removeSection = useCallback(
|
|
|
|
|
(sectionId: string) => {
|
|
|
|
|
if (cfg.sections.length <= 1) return;
|
|
|
|
|
updateConfig({ sections: cfg.sections.filter((s) => s.id !== sectionId) });
|
|
|
|
|
},
|
|
|
|
|
[cfg.sections, updateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const moveSectionUp = useCallback(
|
|
|
|
|
(index: number) => {
|
|
|
|
|
if (index <= 0) return;
|
|
|
|
|
const sections = [...cfg.sections];
|
|
|
|
|
[sections[index - 1], sections[index]] = [
|
|
|
|
|
sections[index],
|
|
|
|
|
sections[index - 1],
|
|
|
|
|
];
|
|
|
|
|
updateConfig({ sections });
|
|
|
|
|
},
|
|
|
|
|
[cfg.sections, updateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleSaveTableChange = useCallback(
|
|
|
|
|
(tableName: string) => {
|
|
|
|
|
const next = tableName === saveTableName ? "" : tableName;
|
|
|
|
|
const saveConfig: PopFieldSaveConfig = {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: next,
|
|
|
|
|
fieldMappings: cfg.saveConfig?.fieldMappings ?? [],
|
|
|
|
|
};
|
|
|
|
|
updateConfig({ saveConfig, targetTable: next });
|
|
|
|
|
},
|
|
|
|
|
[cfg.saveConfig, saveTableName, updateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const allFields = useMemo(() => {
|
|
|
|
|
return cfg.sections.flatMap((s) =>
|
|
|
|
|
(s.fields ?? []).map((f) => ({ field: f, section: s }))
|
|
|
|
|
);
|
|
|
|
|
}, [cfg.sections]);
|
|
|
|
|
|
|
|
|
|
const displayFields = useMemo(() => {
|
|
|
|
|
return cfg.sections
|
|
|
|
|
.filter((s) => migrateStyle(s.style) === "display")
|
|
|
|
|
.flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s })));
|
|
|
|
|
}, [cfg.sections]);
|
|
|
|
|
|
|
|
|
|
const inputFields = useMemo(() => {
|
|
|
|
|
return cfg.sections
|
|
|
|
|
.filter((s) => migrateStyle(s.style) === "input")
|
|
|
|
|
.flatMap((s) => (s.fields ?? []).map((f) => ({ field: f, section: s })));
|
|
|
|
|
}, [cfg.sections]);
|
|
|
|
|
|
|
|
|
|
const hasDisplayFields = displayFields.length > 0;
|
|
|
|
|
const hasInputFields = inputFields.length > 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Tabs defaultValue="layout" className="w-full">
|
|
|
|
|
<TabsList className="h-8 w-full">
|
|
|
|
|
<TabsTrigger value="layout" className="flex-1 text-xs">
|
|
|
|
|
레이아웃
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="save" className="flex-1 text-xs">
|
|
|
|
|
저장
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
<TabsContent value="layout" className="mt-3 space-y-4">
|
|
|
|
|
{cfg.sections.map((section, idx) => (
|
|
|
|
|
<SectionEditor
|
|
|
|
|
key={section.id}
|
|
|
|
|
section={section}
|
|
|
|
|
index={idx}
|
|
|
|
|
canDelete={cfg.sections.length > 1}
|
|
|
|
|
onUpdate={(partial) => updateSection(section.id, partial)}
|
|
|
|
|
onRemove={() => removeSection(section.id)}
|
|
|
|
|
onMoveUp={() => moveSectionUp(idx)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full text-xs"
|
|
|
|
|
onClick={addSection}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
섹션 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
<TabsContent value="save" className="mt-3 space-y-4">
|
|
|
|
|
<SaveTabContent
|
|
|
|
|
cfg={cfg}
|
|
|
|
|
tables={tables}
|
|
|
|
|
saveTableName={saveTableName}
|
|
|
|
|
saveTableOpen={saveTableOpen}
|
|
|
|
|
setSaveTableOpen={setSaveTableOpen}
|
|
|
|
|
readTableOpen={readTableOpen}
|
|
|
|
|
setReadTableOpen={setReadTableOpen}
|
|
|
|
|
allFields={allFields}
|
|
|
|
|
displayFields={displayFields}
|
|
|
|
|
inputFields={inputFields}
|
|
|
|
|
hasDisplayFields={hasDisplayFields}
|
|
|
|
|
hasInputFields={hasInputFields}
|
|
|
|
|
onSaveTableChange={handleSaveTableChange}
|
|
|
|
|
onUpdateConfig={updateConfig}
|
|
|
|
|
/>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// SaveTabContent: 저장 탭 (필드 중심 순차 설정)
|
|
|
|
|
// 1. 테이블 설정 (읽기/저장 테이블 + PK)
|
|
|
|
|
// 2. 읽기 필드 매핑 (display 섹션)
|
|
|
|
|
// 3. 입력 필드 매핑 (input 섹션)
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
interface SaveTabContentProps {
|
|
|
|
|
cfg: PopFieldConfig;
|
|
|
|
|
tables: TableInfo[];
|
|
|
|
|
saveTableName: string;
|
|
|
|
|
saveTableOpen: boolean;
|
|
|
|
|
setSaveTableOpen: (v: boolean) => void;
|
|
|
|
|
readTableOpen: boolean;
|
|
|
|
|
setReadTableOpen: (v: boolean) => void;
|
|
|
|
|
allFields: { field: PopFieldItem; section: PopFieldSection }[];
|
|
|
|
|
displayFields: { field: PopFieldItem; section: PopFieldSection }[];
|
|
|
|
|
inputFields: { field: PopFieldItem; section: PopFieldSection }[];
|
|
|
|
|
hasDisplayFields: boolean;
|
|
|
|
|
hasInputFields: boolean;
|
|
|
|
|
onSaveTableChange: (tableName: string) => void;
|
|
|
|
|
onUpdateConfig: (partial: Partial<PopFieldConfig>) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SaveTabContent({
|
|
|
|
|
cfg,
|
|
|
|
|
tables,
|
|
|
|
|
saveTableName,
|
|
|
|
|
saveTableOpen,
|
|
|
|
|
setSaveTableOpen,
|
|
|
|
|
readTableOpen,
|
|
|
|
|
setReadTableOpen,
|
|
|
|
|
allFields,
|
|
|
|
|
displayFields,
|
|
|
|
|
inputFields,
|
|
|
|
|
hasDisplayFields,
|
|
|
|
|
hasInputFields,
|
|
|
|
|
onSaveTableChange,
|
|
|
|
|
onUpdateConfig,
|
|
|
|
|
}: SaveTabContentProps) {
|
|
|
|
|
const [saveColumns, setSaveColumns] = useState<ColumnInfo[]>([]);
|
|
|
|
|
const [readColumns, setReadColumns] = useState<ColumnInfo[]>([]);
|
|
|
|
|
const [jsonKeysMap, setJsonKeysMap] = useState<Record<string, string[]>>({});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (saveTableName) {
|
|
|
|
|
fetchTableColumns(saveTableName).then(setSaveColumns);
|
|
|
|
|
} else {
|
|
|
|
|
setSaveColumns([]);
|
|
|
|
|
}
|
|
|
|
|
}, [saveTableName]);
|
|
|
|
|
|
|
|
|
|
const readTableName = cfg.readSource?.tableName ?? "";
|
|
|
|
|
const readSameAsSave = readTableName === saveTableName && !!saveTableName;
|
|
|
|
|
const readTableForFetch = readSameAsSave ? saveTableName : readTableName;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (readTableForFetch) {
|
|
|
|
|
fetchTableColumns(readTableForFetch).then(setReadColumns);
|
|
|
|
|
} else {
|
|
|
|
|
setReadColumns([]);
|
|
|
|
|
}
|
|
|
|
|
}, [readTableForFetch]);
|
|
|
|
|
|
|
|
|
|
const fetchJsonKeysForColumn = useCallback(
|
|
|
|
|
async (tableName: string, columnName: string) => {
|
|
|
|
|
const cacheKey = `${tableName}__${columnName}`;
|
|
|
|
|
if (jsonKeysMap[cacheKey]) return;
|
|
|
|
|
try {
|
|
|
|
|
const result = await dataApi.getTableData(tableName, { page: 1, size: 1 });
|
|
|
|
|
const row = result.data?.[0];
|
|
|
|
|
if (!row || !row[columnName]) return;
|
|
|
|
|
const raw = row[columnName];
|
|
|
|
|
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
|
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
|
|
|
setJsonKeysMap((prev) => ({ ...prev, [cacheKey]: Object.keys(parsed).sort() }));
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// JSON 파싱 실패 시 무시
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[jsonKeysMap]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const getJsonKeys = useCallback(
|
|
|
|
|
(tableName: string, columnName: string): string[] => {
|
|
|
|
|
return jsonKeysMap[`${tableName}__${columnName}`] ?? [];
|
|
|
|
|
},
|
|
|
|
|
[jsonKeysMap]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// --- 저장 매핑 로직 ---
|
|
|
|
|
const saveMappings = cfg.saveConfig?.fieldMappings ?? [];
|
|
|
|
|
|
|
|
|
|
const getSaveMappingForField = (fieldId: string): PopFieldSaveMapping => {
|
|
|
|
|
return (
|
|
|
|
|
saveMappings.find((x) => x.fieldId === fieldId) ?? {
|
|
|
|
|
fieldId,
|
|
|
|
|
valueSource: "direct",
|
|
|
|
|
targetColumn: "",
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const syncAndUpdateSaveMappings = useCallback(
|
|
|
|
|
(updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[]) => {
|
|
|
|
|
const fieldIds = new Set(allFields.map(({ field }) => field.id));
|
|
|
|
|
const prev = saveMappings.filter((m) => fieldIds.has(m.fieldId));
|
|
|
|
|
const next = updater ? updater(prev) : prev;
|
|
|
|
|
const added = allFields.filter(
|
|
|
|
|
({ field }) => !next.some((m) => m.fieldId === field.id)
|
|
|
|
|
);
|
|
|
|
|
const merged: PopFieldSaveMapping[] = [
|
|
|
|
|
...next,
|
|
|
|
|
...added.map(({ field }) => ({
|
|
|
|
|
fieldId: field.id,
|
|
|
|
|
valueSource: "direct" as FieldValueSource,
|
|
|
|
|
targetColumn: "",
|
|
|
|
|
})),
|
|
|
|
|
];
|
|
|
|
|
const structureChanged =
|
|
|
|
|
merged.length !== saveMappings.length ||
|
|
|
|
|
merged.some((m, i) => m.fieldId !== saveMappings[i]?.fieldId);
|
|
|
|
|
if (updater || structureChanged) {
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
saveConfig: {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: saveTableName,
|
|
|
|
|
fieldMappings: merged,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[allFields, saveMappings, cfg.saveConfig, saveTableName, onUpdateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const fieldIdsKey = allFields.map(({ field }) => field.id).join(",");
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
syncAndUpdateSaveMappings();
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [fieldIdsKey]);
|
|
|
|
|
|
|
|
|
|
const updateSaveMapping = useCallback(
|
|
|
|
|
(fieldId: string, partial: Partial<PopFieldSaveMapping>) => {
|
|
|
|
|
syncAndUpdateSaveMappings((prev) =>
|
|
|
|
|
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[syncAndUpdateSaveMappings]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// --- 숨은 필드 매핑 로직 ---
|
|
|
|
|
const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? [];
|
|
|
|
|
|
|
|
|
|
const addHiddenMapping = useCallback(() => {
|
|
|
|
|
const newMapping: PopFieldHiddenMapping = {
|
|
|
|
|
id: `hidden_${Date.now()}`,
|
|
|
|
|
valueSource: "db_column",
|
|
|
|
|
targetColumn: "",
|
|
|
|
|
label: "",
|
|
|
|
|
};
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
saveConfig: {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: saveTableName,
|
|
|
|
|
fieldMappings: cfg.saveConfig?.fieldMappings ?? [],
|
|
|
|
|
hiddenMappings: [...hiddenMappings, newMapping],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}, [cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig]);
|
|
|
|
|
|
|
|
|
|
const updateHiddenMapping = useCallback(
|
|
|
|
|
(id: string, partial: Partial<PopFieldHiddenMapping>) => {
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
saveConfig: {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: saveTableName,
|
|
|
|
|
fieldMappings: cfg.saveConfig?.fieldMappings ?? [],
|
|
|
|
|
hiddenMappings: hiddenMappings.map((m) =>
|
|
|
|
|
m.id === id ? { ...m, ...partial } : m
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const removeHiddenMapping = useCallback(
|
|
|
|
|
(id: string) => {
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
saveConfig: {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: saveTableName,
|
|
|
|
|
fieldMappings: cfg.saveConfig?.fieldMappings ?? [],
|
|
|
|
|
hiddenMappings: hiddenMappings.filter((m) => m.id !== id),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[cfg.saveConfig, saveTableName, hiddenMappings, onUpdateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// --- 레이아웃 auto 필드 감지 (입력 섹션에서 inputType=auto인 필드) ---
|
|
|
|
|
const autoInputFields = useMemo(
|
|
|
|
|
() => inputFields.filter(({ field }) => field.inputType === "auto"),
|
|
|
|
|
[inputFields]
|
|
|
|
|
);
|
|
|
|
|
const regularInputFields = useMemo(
|
|
|
|
|
() => inputFields.filter(({ field }) => field.inputType !== "auto"),
|
|
|
|
|
[inputFields]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// --- 자동생성 필드 로직 ---
|
|
|
|
|
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
|
|
|
|
const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]);
|
2026-03-04 19:12:22 +09:00
|
|
|
const [allNumberingRules, setAllNumberingRules] = useState<{ ruleId: string; ruleName: string; tableName: string }[]>([]);
|
|
|
|
|
const [showAllRules, setShowAllRules] = useState(false);
|
2026-02-27 12:48:33 +09:00
|
|
|
|
|
|
|
|
// 레이아웃 auto 필드 → autoGenMappings 자동 동기화
|
|
|
|
|
const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(",");
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (autoInputFields.length === 0) return;
|
|
|
|
|
const current = cfg.saveConfig?.autoGenMappings ?? [];
|
|
|
|
|
const linkedIds = new Set(current.filter((m) => m.linkedFieldId).map((m) => m.linkedFieldId));
|
|
|
|
|
const toAdd: PopFieldAutoGenMapping[] = [];
|
|
|
|
|
for (const { field } of autoInputFields) {
|
|
|
|
|
if (!linkedIds.has(field.id)) {
|
|
|
|
|
toAdd.push({
|
|
|
|
|
id: `autogen_linked_${field.id}`,
|
|
|
|
|
linkedFieldId: field.id,
|
|
|
|
|
label: field.labelText || "",
|
|
|
|
|
targetColumn: "",
|
|
|
|
|
numberingRuleId: field.autoNumber?.numberingRuleId ?? "",
|
2026-03-04 19:12:22 +09:00
|
|
|
showInForm: false,
|
2026-02-27 12:48:33 +09:00
|
|
|
showResultModal: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (toAdd.length > 0) {
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
saveConfig: {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: saveTableName,
|
|
|
|
|
fieldMappings: cfg.saveConfig?.fieldMappings ?? [],
|
|
|
|
|
autoGenMappings: [...current, ...toAdd],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [autoFieldIdsKey]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (saveTableName) {
|
|
|
|
|
getAvailableNumberingRulesForScreen(saveTableName)
|
|
|
|
|
.then((res) => {
|
|
|
|
|
if (res.success && Array.isArray(res.data)) {
|
|
|
|
|
setNumberingRules(
|
|
|
|
|
res.data.map((r: any) => ({
|
|
|
|
|
ruleId: String(r.ruleId ?? r.rule_id ?? ""),
|
|
|
|
|
ruleName: String(r.ruleName ?? r.rule_name ?? ""),
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => setNumberingRules([]));
|
|
|
|
|
}
|
|
|
|
|
}, [saveTableName]);
|
|
|
|
|
|
2026-03-04 19:12:22 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!showAllRules) return;
|
|
|
|
|
if (allNumberingRules.length > 0) return;
|
|
|
|
|
getNumberingRules()
|
|
|
|
|
.then((res) => {
|
|
|
|
|
if (res.success && Array.isArray(res.data)) {
|
|
|
|
|
setAllNumberingRules(
|
|
|
|
|
res.data.map((r: any) => ({
|
|
|
|
|
ruleId: String(r.ruleId ?? r.rule_id ?? ""),
|
|
|
|
|
ruleName: String(r.ruleName ?? r.rule_name ?? ""),
|
|
|
|
|
tableName: String(r.tableName ?? r.table_name ?? ""),
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => setAllNumberingRules([]));
|
|
|
|
|
}, [showAllRules, allNumberingRules.length]);
|
|
|
|
|
|
2026-02-27 12:48:33 +09:00
|
|
|
const addAutoGenMapping = useCallback(() => {
|
|
|
|
|
const newMapping: PopFieldAutoGenMapping = {
|
|
|
|
|
id: `autogen_${Date.now()}`,
|
|
|
|
|
label: "",
|
|
|
|
|
targetColumn: "",
|
|
|
|
|
numberingRuleId: "",
|
|
|
|
|
showInForm: false,
|
|
|
|
|
showResultModal: true,
|
|
|
|
|
};
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
saveConfig: {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: saveTableName,
|
|
|
|
|
fieldMappings: cfg.saveConfig?.fieldMappings ?? [],
|
|
|
|
|
autoGenMappings: [...autoGenMappings, newMapping],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}, [cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig]);
|
|
|
|
|
|
|
|
|
|
const updateAutoGenMapping = useCallback(
|
|
|
|
|
(id: string, partial: Partial<PopFieldAutoGenMapping>) => {
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
saveConfig: {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: saveTableName,
|
|
|
|
|
fieldMappings: cfg.saveConfig?.fieldMappings ?? [],
|
|
|
|
|
autoGenMappings: autoGenMappings.map((m) =>
|
|
|
|
|
m.id === id ? { ...m, ...partial } : m
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const removeAutoGenMapping = useCallback(
|
|
|
|
|
(id: string) => {
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
saveConfig: {
|
|
|
|
|
...cfg.saveConfig,
|
|
|
|
|
tableName: saveTableName,
|
|
|
|
|
fieldMappings: cfg.saveConfig?.fieldMappings ?? [],
|
|
|
|
|
autoGenMappings: autoGenMappings.filter((m) => m.id !== id),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[cfg.saveConfig, saveTableName, autoGenMappings, onUpdateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// --- 읽기 매핑 로직 ---
|
|
|
|
|
const getReadMappingForField = (fieldId: string): PopFieldReadMapping => {
|
|
|
|
|
return (
|
|
|
|
|
cfg.readSource?.fieldMappings?.find((x) => x.fieldId === fieldId) ?? {
|
|
|
|
|
fieldId,
|
|
|
|
|
valueSource: "db_column",
|
|
|
|
|
columnName: "",
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateReadMapping = useCallback(
|
|
|
|
|
(fieldId: string, partial: Partial<PopFieldReadMapping>) => {
|
|
|
|
|
const prev = cfg.readSource?.fieldMappings ?? [];
|
|
|
|
|
const next = prev.map((m) =>
|
|
|
|
|
m.fieldId === fieldId ? { ...m, ...partial } : m
|
|
|
|
|
);
|
|
|
|
|
if (!next.some((m) => m.fieldId === fieldId)) {
|
|
|
|
|
next.push({ fieldId, valueSource: "db_column", columnName: "", ...partial });
|
|
|
|
|
}
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
readSource: {
|
|
|
|
|
...cfg.readSource,
|
|
|
|
|
tableName: readTableName || saveTableName,
|
|
|
|
|
pkColumn: cfg.readSource?.pkColumn ?? "",
|
|
|
|
|
fieldMappings: next,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[cfg.readSource, readTableName, saveTableName, onUpdateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// --- 읽기 테이블 변경 ---
|
|
|
|
|
const handleReadSameAsSaveChange = useCallback(
|
|
|
|
|
(checked: boolean) => {
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
readSource: {
|
|
|
|
|
tableName: checked ? saveTableName : "",
|
|
|
|
|
pkColumn: checked ? (cfg.readSource?.pkColumn ?? "") : "",
|
|
|
|
|
fieldMappings: cfg.readSource?.fieldMappings ?? [],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[saveTableName, cfg.readSource, onUpdateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleReadTableChange = useCallback(
|
|
|
|
|
(tableName: string) => {
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
readSource: {
|
|
|
|
|
...cfg.readSource,
|
|
|
|
|
tableName,
|
|
|
|
|
pkColumn: cfg.readSource?.pkColumn ?? "",
|
|
|
|
|
fieldMappings: cfg.readSource?.fieldMappings ?? [],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[cfg.readSource, onUpdateConfig]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const colName = (c: ColumnInfo) => c.name;
|
|
|
|
|
|
|
|
|
|
const noFields = allFields.length === 0;
|
|
|
|
|
|
|
|
|
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
|
|
|
const toggleSection = useCallback((key: string) => {
|
|
|
|
|
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{noFields && (
|
|
|
|
|
<p className="rounded-md border bg-muted/30 px-3 py-4 text-center text-xs text-muted-foreground">
|
|
|
|
|
레이아웃 탭에서 섹션과 필드를 먼저 추가해주세요.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── 1. 테이블 설정 ── */}
|
|
|
|
|
{!noFields && (
|
|
|
|
|
<div className="rounded-md border bg-card">
|
|
|
|
|
<div
|
|
|
|
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
|
|
|
|
onClick={() => toggleSection("table")}
|
|
|
|
|
>
|
|
|
|
|
{collapsed["table"] ? (
|
|
|
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs font-medium">테이블 설정</span>
|
|
|
|
|
</div>
|
|
|
|
|
{!collapsed["table"] && <div className="space-y-3 border-t p-3">
|
|
|
|
|
{/* 읽기 테이블 (display 섹션이 있을 때만) */}
|
|
|
|
|
{hasDisplayFields && (
|
|
|
|
|
<>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">읽기 테이블</Label>
|
|
|
|
|
<div className="mt-1 flex items-center gap-2">
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<Switch
|
|
|
|
|
checked={readSameAsSave}
|
|
|
|
|
onCheckedChange={handleReadSameAsSaveChange}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">
|
|
|
|
|
저장과 동일
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{!readSameAsSave && (
|
|
|
|
|
<Popover open={readTableOpen} onOpenChange={setReadTableOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
className="mt-1 h-7 w-full justify-between text-xs"
|
|
|
|
|
>
|
|
|
|
|
{readTableName || "테이블 선택"}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
align="start"
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty className="text-xs">
|
|
|
|
|
테이블을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{tables.map((t) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={t.tableName}
|
|
|
|
|
value={t.tableName}
|
|
|
|
|
onSelect={(v) => {
|
|
|
|
|
handleReadTableChange(v);
|
|
|
|
|
setReadTableOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
readTableName === t.tableName ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{t.tableName}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">PK 컬럼</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={cfg.readSource?.pkColumn ?? ""}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdateConfig({
|
|
|
|
|
readSource: {
|
|
|
|
|
...cfg.readSource,
|
|
|
|
|
tableName: readTableName || saveTableName,
|
|
|
|
|
pkColumn: v,
|
|
|
|
|
fieldMappings: cfg.readSource?.fieldMappings ?? [],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
|
|
|
|
<SelectValue placeholder="PK 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{readColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 저장 테이블 */}
|
|
|
|
|
{hasInputFields && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">저장 테이블</Label>
|
|
|
|
|
<Popover open={saveTableOpen} onOpenChange={setSaveTableOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
className="mt-1 h-7 w-full justify-between text-xs"
|
|
|
|
|
>
|
|
|
|
|
{saveTableName || "테이블 선택"}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
align="start"
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty className="text-xs">
|
|
|
|
|
테이블을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{tables.map((t) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={t.tableName}
|
|
|
|
|
value={t.tableName}
|
|
|
|
|
onSelect={(v) => {
|
|
|
|
|
onSaveTableChange(v === saveTableName ? "" : v);
|
|
|
|
|
setSaveTableOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
saveTableName === t.tableName ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{t.tableName}
|
|
|
|
|
{t.displayName && t.displayName !== t.tableName && (
|
|
|
|
|
<span className="ml-1 text-muted-foreground">
|
|
|
|
|
({t.displayName})
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── 2. 읽기 필드 매핑 (display) ── */}
|
|
|
|
|
{hasDisplayFields && (readTableForFetch || readSameAsSave) && (
|
|
|
|
|
<div className="rounded-md border bg-card">
|
|
|
|
|
<div
|
|
|
|
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
|
|
|
|
onClick={() => toggleSection("read")}
|
|
|
|
|
>
|
|
|
|
|
{collapsed["read"] ? (
|
|
|
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs font-medium">읽기 필드</span>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">
|
|
|
|
|
(읽기 폼)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{!collapsed["read"] && <div className="space-y-2 border-t p-3">
|
|
|
|
|
{readColumns.length === 0 ? (
|
|
|
|
|
<p className="py-2 text-xs text-muted-foreground">
|
|
|
|
|
읽기 테이블의 컬럼을 불러오는 중...
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
displayFields.map(({ field }) => {
|
|
|
|
|
const m = getReadMappingForField(field.id);
|
|
|
|
|
const sm = getSaveMappingForField(field.id);
|
|
|
|
|
const isJson = m.valueSource === "json_extract";
|
|
|
|
|
return (
|
|
|
|
|
<div key={field.id} className="space-y-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="w-20 shrink-0 truncate text-xs font-medium">
|
|
|
|
|
{field.labelText || "(미설정)"}
|
|
|
|
|
</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={m.valueSource === "direct" ? "db_column" : m.valueSource}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateReadMapping(field.id, {
|
|
|
|
|
valueSource: v as FieldValueSource,
|
|
|
|
|
columnName: "",
|
|
|
|
|
jsonKey: undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 w-24 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="db_column" className="text-xs">
|
|
|
|
|
DB 컬럼
|
|
|
|
|
</SelectItem>
|
|
|
|
|
<SelectItem value="json_extract" className="text-xs">
|
|
|
|
|
JSON
|
|
|
|
|
</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{!isJson && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-muted-foreground">←</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={m.columnName || ""}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateReadMapping(field.id, { columnName: v })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
|
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{readColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{isJson && (
|
|
|
|
|
<div className="ml-[88px] flex items-center gap-1">
|
|
|
|
|
<span className="text-muted-foreground">←</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={m.columnName || ""}
|
|
|
|
|
onValueChange={(v) => {
|
|
|
|
|
updateReadMapping(field.id, { columnName: v, jsonKey: undefined });
|
|
|
|
|
if (readTableForFetch && v) {
|
|
|
|
|
fetchJsonKeysForColumn(readTableForFetch, v);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
|
|
|
<SelectValue placeholder="JSON 컬럼" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{readColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">.</span>
|
|
|
|
|
<JsonKeySelect
|
|
|
|
|
value={m.jsonKey ?? ""}
|
|
|
|
|
keys={readTableForFetch && m.columnName ? getJsonKeys(readTableForFetch, m.columnName) : []}
|
|
|
|
|
onValueChange={(v) => updateReadMapping(field.id, { jsonKey: v })}
|
|
|
|
|
onOpen={() => {
|
|
|
|
|
if (readTableForFetch && m.columnName) {
|
|
|
|
|
fetchJsonKeysForColumn(readTableForFetch, m.columnName);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{saveTableName && saveColumns.length > 0 && (
|
|
|
|
|
<div className="ml-[88px] flex items-center gap-1">
|
|
|
|
|
<span className="text-muted-foreground">→</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={sm.targetColumn || "__none__"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateSaveMapping(field.id, {
|
|
|
|
|
targetColumn: v === "__none__" ? "" : v,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
|
|
|
<SelectValue placeholder="저장 컬럼" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__" className="text-xs">
|
|
|
|
|
저장 안함
|
|
|
|
|
</SelectItem>
|
|
|
|
|
{saveColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── 3. 입력 필드 매핑 (input, auto 타입 제외) ── */}
|
|
|
|
|
{regularInputFields.length > 0 && saveTableName && (
|
|
|
|
|
<div className="rounded-md border bg-card">
|
|
|
|
|
<div
|
|
|
|
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
|
|
|
|
onClick={() => toggleSection("input")}
|
|
|
|
|
>
|
|
|
|
|
{collapsed["input"] ? (
|
|
|
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs font-medium">입력 필드</span>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">
|
|
|
|
|
(입력 폼 → 저장)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{!collapsed["input"] && <div className="space-y-2 border-t p-3">
|
|
|
|
|
{saveColumns.length === 0 ? (
|
|
|
|
|
<p className="py-2 text-xs text-muted-foreground">
|
|
|
|
|
저장 테이블의 컬럼을 불러오는 중...
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
regularInputFields.map(({ field }) => {
|
|
|
|
|
const m = getSaveMappingForField(field.id);
|
|
|
|
|
return (
|
|
|
|
|
<div key={field.id} className="flex items-center gap-2">
|
|
|
|
|
<span className="w-20 shrink-0 truncate text-xs font-medium">
|
|
|
|
|
{field.labelText || "(미설정)"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-muted-foreground">→</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={m.targetColumn || "__none__"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateSaveMapping(field.id, {
|
|
|
|
|
targetColumn: v === "__none__" ? "" : v,
|
|
|
|
|
valueSource: "direct",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
|
|
|
<SelectValue placeholder="저장 컬럼" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__" className="text-xs">
|
|
|
|
|
매핑 안함
|
|
|
|
|
</SelectItem>
|
|
|
|
|
{saveColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── 4. 숨은 필드 매핑 (읽기 필드와 동일한 소스 구조) ── */}
|
|
|
|
|
{saveTableName && (
|
|
|
|
|
<div className="rounded-md border bg-card">
|
|
|
|
|
<div
|
|
|
|
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
|
|
|
|
onClick={() => toggleSection("hidden")}
|
|
|
|
|
>
|
|
|
|
|
{collapsed["hidden"] ? (
|
|
|
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs font-medium">숨은 필드</span>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">
|
|
|
|
|
(UI 미표시, 전달 데이터에서 추출하여 저장)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3">
|
|
|
|
|
{hiddenMappings.map((m) => {
|
|
|
|
|
const isJson = m.valueSource === "json_extract";
|
|
|
|
|
return (
|
|
|
|
|
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
value={m.label || ""}
|
|
|
|
|
onChange={(e) => updateHiddenMapping(m.id, { label: e.target.value })}
|
|
|
|
|
placeholder="라벨 (관리용)"
|
|
|
|
|
className="h-7 flex-1 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-6 w-6 shrink-0 text-destructive"
|
|
|
|
|
onClick={() => removeHiddenMapping(m.id)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Select
|
|
|
|
|
value={m.valueSource || "db_column"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateHiddenMapping(m.id, {
|
|
|
|
|
valueSource: v as HiddenValueSource,
|
|
|
|
|
sourceDbColumn: undefined,
|
|
|
|
|
sourceJsonColumn: undefined,
|
|
|
|
|
sourceJsonKey: undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 w-24 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="db_column" className="text-xs">DB 컬럼</SelectItem>
|
|
|
|
|
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{!isJson && (
|
|
|
|
|
<>
|
|
|
|
|
<Select
|
|
|
|
|
value={m.sourceDbColumn || "__none__"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateHiddenMapping(m.id, { sourceDbColumn: v === "__none__" ? "" : v })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
|
|
|
<SelectValue placeholder="소스 컬럼" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__" className="text-xs">선택</SelectItem>
|
|
|
|
|
{readColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{isJson && (
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Select
|
|
|
|
|
value={m.sourceJsonColumn || "__none__"}
|
|
|
|
|
onValueChange={(v) => {
|
|
|
|
|
const col = v === "__none__" ? "" : v;
|
|
|
|
|
updateHiddenMapping(m.id, { sourceJsonColumn: col, sourceJsonKey: "" });
|
|
|
|
|
if (readTableForFetch && col) {
|
|
|
|
|
fetchJsonKeysForColumn(readTableForFetch, col);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
|
|
|
<SelectValue placeholder="JSON 컬럼" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__" className="text-xs">선택</SelectItem>
|
|
|
|
|
{readColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">.</span>
|
|
|
|
|
<JsonKeySelect
|
|
|
|
|
value={m.sourceJsonKey ?? ""}
|
|
|
|
|
keys={readTableForFetch && m.sourceJsonColumn ? getJsonKeys(readTableForFetch, m.sourceJsonColumn) : []}
|
|
|
|
|
onValueChange={(v) => updateHiddenMapping(m.id, { sourceJsonKey: v })}
|
|
|
|
|
onOpen={() => {
|
|
|
|
|
if (readTableForFetch && m.sourceJsonColumn) {
|
|
|
|
|
fetchJsonKeysForColumn(readTableForFetch, m.sourceJsonColumn);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-muted-foreground">→</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={m.targetColumn || "__none__"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateHiddenMapping(m.id, { targetColumn: v === "__none__" ? "" : v })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
|
|
|
<SelectValue placeholder="저장 컬럼" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__" className="text-xs">선택</SelectItem>
|
|
|
|
|
{saveColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full text-xs"
|
|
|
|
|
onClick={addHiddenMapping}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
숨은 필드 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── 5. 자동생성 필드 ── */}
|
|
|
|
|
{saveTableName && (
|
|
|
|
|
<div className="rounded-md border bg-card">
|
|
|
|
|
<div
|
|
|
|
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
|
|
|
|
onClick={() => toggleSection("autogen")}
|
|
|
|
|
>
|
|
|
|
|
{collapsed["autogen"] ? (
|
|
|
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="text-xs font-medium">자동생성 필드</span>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">
|
|
|
|
|
(저장 시 서버에서 채번)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{!collapsed["autogen"] && <div className="space-y-3 border-t p-3">
|
|
|
|
|
{autoGenMappings.map((m) => {
|
|
|
|
|
const isLinked = !!m.linkedFieldId;
|
|
|
|
|
return (
|
|
|
|
|
<div key={m.id} className="space-y-2 rounded border bg-background p-2">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{isLinked && (
|
|
|
|
|
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
|
|
|
|
레이아웃
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<Input
|
|
|
|
|
value={m.label}
|
|
|
|
|
onChange={(e) => updateAutoGenMapping(m.id, { label: e.target.value })}
|
|
|
|
|
placeholder="라벨 (예: 입고번호)"
|
|
|
|
|
className="h-7 flex-1 text-xs"
|
|
|
|
|
readOnly={isLinked}
|
|
|
|
|
/>
|
|
|
|
|
{!isLinked && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-6 w-6 shrink-0 text-destructive"
|
|
|
|
|
onClick={() => removeAutoGenMapping(m.id)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-muted-foreground">→</span>
|
|
|
|
|
<Select
|
|
|
|
|
value={m.targetColumn || "__none__"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateAutoGenMapping(m.id, { targetColumn: v === "__none__" ? "" : v })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
|
|
|
|
<SelectValue placeholder="저장 컬럼" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__" className="text-xs">
|
|
|
|
|
선택
|
|
|
|
|
</SelectItem>
|
|
|
|
|
{saveColumns.map((c) => (
|
|
|
|
|
<SelectItem key={colName(c)} value={colName(c)} className="text-xs">
|
|
|
|
|
{colName(c)}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-03-04 19:12:22 +09:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label className="text-[10px]">채번 규칙</Label>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Switch
|
|
|
|
|
checked={showAllRules}
|
|
|
|
|
onCheckedChange={setShowAllRules}
|
|
|
|
|
className="h-3.5 w-7 data-[state=checked]:bg-primary [&>span]:h-2.5 [&>span]:w-2.5"
|
|
|
|
|
/>
|
|
|
|
|
<Label className="cursor-pointer text-[10px] text-muted-foreground" onClick={() => setShowAllRules(!showAllRules)}>
|
|
|
|
|
전체 보기
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-27 12:48:33 +09:00
|
|
|
<Select
|
|
|
|
|
value={m.numberingRuleId || "__none__"}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
updateAutoGenMapping(m.id, { numberingRuleId: v === "__none__" ? "" : v })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-0.5 h-7 text-xs">
|
|
|
|
|
<SelectValue placeholder="채번 규칙 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="__none__" className="text-xs">
|
|
|
|
|
선택
|
|
|
|
|
</SelectItem>
|
2026-03-04 19:12:22 +09:00
|
|
|
{showAllRules
|
|
|
|
|
? allNumberingRules.map((r) => (
|
|
|
|
|
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
|
|
|
|
|
{r.ruleName || r.ruleId}
|
|
|
|
|
<span className="ml-1 text-muted-foreground">({r.tableName || "-"})</span>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
: numberingRules.map((r) => (
|
|
|
|
|
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
|
|
|
|
|
{r.ruleName || r.ruleId}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
}
|
2026-02-27 12:48:33 +09:00
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
{!isLinked && (
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<Switch
|
|
|
|
|
checked={m.showInForm}
|
|
|
|
|
onCheckedChange={(v) => updateAutoGenMapping(m.id, { showInForm: v })}
|
|
|
|
|
/>
|
|
|
|
|
<Label className="text-[10px]">필드 표시</Label>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<Switch
|
|
|
|
|
checked={m.showResultModal}
|
|
|
|
|
onCheckedChange={(v) => updateAutoGenMapping(m.id, { showResultModal: v })}
|
|
|
|
|
/>
|
|
|
|
|
<Label className="text-[10px]">결과 모달</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full text-xs"
|
|
|
|
|
onClick={addAutoGenMapping}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
자동생성 필드 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 저장 테이블 미선택 안내 */}
|
|
|
|
|
{(regularInputFields.length > 0 || autoInputFields.length > 0) && !saveTableName && !noFields && (
|
|
|
|
|
<p className="rounded-md border bg-muted/30 px-3 py-3 text-center text-xs text-muted-foreground">
|
|
|
|
|
저장 테이블을 선택하면 입력 필드 매핑이 표시됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// SectionEditor: 섹션 단위 편집
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
interface SectionEditorProps {
|
|
|
|
|
section: PopFieldSection;
|
|
|
|
|
index: number;
|
|
|
|
|
canDelete: boolean;
|
|
|
|
|
onUpdate: (partial: Partial<PopFieldSection>) => void;
|
|
|
|
|
onRemove: () => void;
|
|
|
|
|
onMoveUp: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function migrateStyle(style: string): FieldSectionStyle {
|
|
|
|
|
if (style === "display" || style === "input") return style;
|
|
|
|
|
if (style === "summary") return "display";
|
|
|
|
|
if (style === "form") return "input";
|
|
|
|
|
return "input";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SectionEditor({
|
|
|
|
|
section,
|
|
|
|
|
index,
|
|
|
|
|
canDelete,
|
|
|
|
|
onUpdate,
|
|
|
|
|
onRemove,
|
|
|
|
|
onMoveUp,
|
|
|
|
|
}: SectionEditorProps) {
|
|
|
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
|
|
|
const resolvedStyle = migrateStyle(section.style);
|
|
|
|
|
|
|
|
|
|
const sectionFields = section.fields || [];
|
|
|
|
|
|
|
|
|
|
const updateField = useCallback(
|
|
|
|
|
(fieldId: string, partial: Partial<PopFieldItem>) => {
|
|
|
|
|
const fields = sectionFields.map((f) =>
|
|
|
|
|
f.id === fieldId ? { ...f, ...partial } : f
|
|
|
|
|
);
|
|
|
|
|
onUpdate({ fields });
|
|
|
|
|
},
|
|
|
|
|
[sectionFields, onUpdate]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const addField = useCallback(() => {
|
|
|
|
|
const fieldId = `field_${Date.now()}`;
|
|
|
|
|
const newField: PopFieldItem = {
|
|
|
|
|
id: fieldId,
|
|
|
|
|
inputType: "text",
|
|
|
|
|
fieldName: fieldId,
|
|
|
|
|
labelText: "",
|
|
|
|
|
readOnly: false,
|
|
|
|
|
};
|
|
|
|
|
onUpdate({ fields: [...sectionFields, newField] });
|
|
|
|
|
}, [sectionFields, onUpdate]);
|
|
|
|
|
|
|
|
|
|
const removeField = useCallback(
|
|
|
|
|
(fieldId: string) => {
|
|
|
|
|
onUpdate({ fields: sectionFields.filter((f) => f.id !== fieldId) });
|
|
|
|
|
},
|
|
|
|
|
[sectionFields, onUpdate]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-md border bg-card">
|
|
|
|
|
{/* 섹션 헤더 */}
|
|
|
|
|
<div
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 px-3 py-2"
|
|
|
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
|
|
|
>
|
|
|
|
|
{collapsed ? (
|
|
|
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="flex-1 text-xs font-medium">
|
|
|
|
|
섹션 {index + 1}
|
|
|
|
|
{section.label && ` - ${section.label}`}
|
|
|
|
|
</span>
|
|
|
|
|
{index > 0 && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-5 w-5"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onMoveUp();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<GripVertical className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{canDelete && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-5 w-5 text-destructive"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onRemove();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<div className="space-y-3 border-t px-3 py-3">
|
|
|
|
|
{/* 섹션 라벨 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">섹션 라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={section.label || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ label: e.target.value })}
|
|
|
|
|
placeholder="선택사항"
|
|
|
|
|
className="mt-1 h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 스타일 + 열 수 (가로 배치) */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">스타일</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={resolvedStyle}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({ style: v as FieldSectionStyle })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{(
|
|
|
|
|
Object.entries(FIELD_SECTION_STYLE_LABELS) as [
|
|
|
|
|
FieldSectionStyle,
|
|
|
|
|
string,
|
|
|
|
|
][]
|
|
|
|
|
).map(([val, label]) => (
|
|
|
|
|
<SelectItem key={val} value={val} className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">열 수</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={String(section.columns)}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({
|
|
|
|
|
columns: v === "auto" ? "auto" : (Number(v) as 1 | 2 | 3 | 4),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="auto" className="text-xs">
|
|
|
|
|
자동
|
|
|
|
|
</SelectItem>
|
|
|
|
|
<SelectItem value="1" className="text-xs">
|
|
|
|
|
1열
|
|
|
|
|
</SelectItem>
|
|
|
|
|
<SelectItem value="2" className="text-xs">
|
|
|
|
|
2열
|
|
|
|
|
</SelectItem>
|
|
|
|
|
<SelectItem value="3" className="text-xs">
|
|
|
|
|
3열
|
|
|
|
|
</SelectItem>
|
|
|
|
|
<SelectItem value="4" className="text-xs">
|
|
|
|
|
4열
|
|
|
|
|
</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 라벨 표시 토글 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<Label className="text-xs">라벨 표시</Label>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={section.showLabels}
|
|
|
|
|
onCheckedChange={(v) => onUpdate({ showLabels: v })}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 커스텀 색상 */}
|
|
|
|
|
<AppearanceEditor
|
|
|
|
|
style={resolvedStyle}
|
|
|
|
|
appearance={section.appearance}
|
|
|
|
|
onUpdate={(appearance) => onUpdate({ appearance })}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 필드 목록 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-xs text-muted-foreground">
|
|
|
|
|
필드 ({sectionFields.length})
|
|
|
|
|
</Label>
|
|
|
|
|
{sectionFields.map((field) => (
|
|
|
|
|
<FieldItemEditor
|
|
|
|
|
key={field.id}
|
|
|
|
|
field={field}
|
|
|
|
|
sectionStyle={resolvedStyle}
|
|
|
|
|
onUpdate={(partial) => updateField(field.id, partial)}
|
|
|
|
|
onRemove={() => removeField(field.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full text-xs"
|
|
|
|
|
onClick={addField}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
필드 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// FieldItemEditor: 필드 단위 편집
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
interface FieldItemEditorProps {
|
|
|
|
|
field: PopFieldItem;
|
|
|
|
|
sectionStyle?: FieldSectionStyle;
|
|
|
|
|
onUpdate: (partial: Partial<PopFieldItem>) => void;
|
|
|
|
|
onRemove: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FieldItemEditor({
|
|
|
|
|
field,
|
|
|
|
|
sectionStyle,
|
|
|
|
|
onUpdate,
|
|
|
|
|
onRemove,
|
|
|
|
|
}: FieldItemEditorProps) {
|
|
|
|
|
const isDisplay = sectionStyle === "display";
|
|
|
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded border bg-background">
|
|
|
|
|
{/* 필드 헤더 */}
|
|
|
|
|
<div
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 px-2 py-1.5"
|
|
|
|
|
onClick={() => setExpanded(!expanded)}
|
|
|
|
|
>
|
|
|
|
|
{expanded ? (
|
|
|
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
|
|
|
)}
|
|
|
|
|
<span className="flex-1 truncate text-xs">
|
|
|
|
|
{field.labelText || "(미설정)"}
|
|
|
|
|
<span className="ml-1 text-muted-foreground">
|
|
|
|
|
[{FIELD_INPUT_TYPE_LABELS[field.inputType]}]
|
|
|
|
|
</span>
|
|
|
|
|
{field.readOnly && (
|
|
|
|
|
<span className="ml-1 text-orange-500">(읽기전용)</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-5 w-5 text-destructive"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onRemove();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{expanded && (
|
|
|
|
|
<div className="space-y-2 border-t px-2 py-2">
|
|
|
|
|
{/* 라벨 + 타입 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={field.labelText || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ labelText: e.target.value })}
|
|
|
|
|
placeholder="표시 라벨"
|
|
|
|
|
className="mt-0.5 h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">타입</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={field.inputType}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({ inputType: v as FieldInputType })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-0.5 h-7 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{(
|
|
|
|
|
Object.entries(FIELD_INPUT_TYPE_LABELS) as [
|
|
|
|
|
FieldInputType,
|
|
|
|
|
string,
|
|
|
|
|
][]
|
|
|
|
|
).map(([val, label]) => (
|
|
|
|
|
<SelectItem key={val} value={val} className="text-xs">
|
|
|
|
|
{label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 플레이스홀더 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">플레이스홀더</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={field.placeholder || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ placeholder: e.target.value })}
|
|
|
|
|
placeholder="힌트 텍스트"
|
|
|
|
|
className="mt-0.5 h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 읽기전용 + 필수 (입력 폼에서만 표시) */}
|
|
|
|
|
{!isDisplay && (
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<Switch
|
|
|
|
|
checked={field.readOnly || false}
|
|
|
|
|
onCheckedChange={(v) => onUpdate({ readOnly: v })}
|
|
|
|
|
/>
|
|
|
|
|
<Label className="text-[10px]">읽기전용</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<Switch
|
|
|
|
|
checked={field.validation?.required || false}
|
|
|
|
|
onCheckedChange={(v) =>
|
|
|
|
|
onUpdate({
|
|
|
|
|
validation: { ...field.validation, required: v },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<Label className="text-[10px]">필수</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 단위 (number, numpad) */}
|
|
|
|
|
{(field.inputType === "number" || field.inputType === "numpad") && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">단위</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={field.unit || ""}
|
|
|
|
|
onChange={(e) => onUpdate({ unit: e.target.value })}
|
|
|
|
|
placeholder="EA, KG 등"
|
|
|
|
|
className="mt-0.5 h-7 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* select 전용: 옵션 소스 */}
|
|
|
|
|
{field.inputType === "select" && (
|
|
|
|
|
<SelectSourceEditor
|
|
|
|
|
source={field.selectSource}
|
|
|
|
|
onUpdate={(source) => onUpdate({ selectSource: source })}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-04 19:12:22 +09:00
|
|
|
{/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */}
|
2026-02-27 12:48:33 +09:00
|
|
|
{field.inputType === "auto" && (
|
2026-03-04 19:12:22 +09:00
|
|
|
<div className="rounded border bg-muted/30 p-2">
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
채번 규칙은 [저장] 탭 > 자동생성 필드에서 설정합니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-02-27 12:48:33 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// SelectSourceEditor: select 옵션 소스 편집
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
function SelectSourceEditor({
|
|
|
|
|
source,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
source?: FieldSelectSource;
|
|
|
|
|
onUpdate: (source: FieldSelectSource) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const current: FieldSelectSource = source || {
|
|
|
|
|
type: "static",
|
|
|
|
|
staticOptions: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2 rounded border bg-muted/30 p-2">
|
|
|
|
|
<Label className="text-[10px] text-muted-foreground">옵션 소스</Label>
|
|
|
|
|
|
|
|
|
|
<Select
|
|
|
|
|
value={current.type}
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
onUpdate({ ...current, type: v as "static" | "table" })
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-7 text-xs">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="static" className="text-xs">
|
|
|
|
|
직접 입력
|
|
|
|
|
</SelectItem>
|
|
|
|
|
<SelectItem value="table" className="text-xs">
|
|
|
|
|
테이블 조회
|
|
|
|
|
</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
{current.type === "static" && (
|
|
|
|
|
<StaticOptionsEditor
|
|
|
|
|
options={current.staticOptions || []}
|
|
|
|
|
onUpdate={(opts) => onUpdate({ ...current, staticOptions: opts })}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{current.type === "table" && (
|
|
|
|
|
<TableSourceEditor
|
|
|
|
|
source={current}
|
|
|
|
|
onUpdate={(partial) => onUpdate({ ...current, ...partial })}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// StaticOptionsEditor: 정적 옵션 CRUD
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
function StaticOptionsEditor({
|
|
|
|
|
options,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
options: { value: string; label: string }[];
|
|
|
|
|
onUpdate: (options: { value: string; label: string }[]) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{options.map((opt, idx) => (
|
|
|
|
|
<div key={idx} className="flex items-center gap-1">
|
|
|
|
|
<Input
|
|
|
|
|
value={opt.value}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const next = [...options];
|
|
|
|
|
next[idx] = { ...opt, value: e.target.value };
|
|
|
|
|
onUpdate(next);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="값"
|
|
|
|
|
className="h-6 flex-1 text-[10px]"
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
value={opt.label}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const next = [...options];
|
|
|
|
|
next[idx] = { ...opt, label: e.target.value };
|
|
|
|
|
onUpdate(next);
|
|
|
|
|
}}
|
|
|
|
|
placeholder="표시"
|
|
|
|
|
className="h-6 flex-1 text-[10px]"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-5 w-5 text-destructive"
|
|
|
|
|
onClick={() => onUpdate(options.filter((_, i) => i !== idx))}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-6 w-full text-[10px]"
|
|
|
|
|
onClick={() => onUpdate([...options, { value: "", label: "" }])}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
|
|
|
옵션 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// TableSourceEditor: 테이블 소스 설정
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
function TableSourceEditor({
|
|
|
|
|
source,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
source: FieldSelectSource;
|
|
|
|
|
onUpdate: (partial: Partial<FieldSelectSource>) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
|
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
|
|
|
const [tblOpen, setTblOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchTableList().then(setTables);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (source.tableName) {
|
|
|
|
|
fetchTableColumns(source.tableName).then(setColumns);
|
|
|
|
|
} else {
|
|
|
|
|
setColumns([]);
|
|
|
|
|
}
|
|
|
|
|
}, [source.tableName]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{/* 테이블 Combobox */}
|
|
|
|
|
<Popover open={tblOpen} onOpenChange={setTblOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
className="h-7 w-full justify-between text-xs"
|
|
|
|
|
>
|
|
|
|
|
{source.tableName || "테이블 선택"}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
className="p-0"
|
|
|
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
|
|
|
align="start"
|
|
|
|
|
>
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty className="text-xs">
|
|
|
|
|
테이블을 찾을 수 없습니다.
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{tables.map((t) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={t.tableName}
|
|
|
|
|
value={t.tableName}
|
|
|
|
|
onSelect={(v) => {
|
|
|
|
|
onUpdate({ tableName: v });
|
|
|
|
|
setTblOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
source.tableName === t.tableName
|
|
|
|
|
? "opacity-100"
|
|
|
|
|
: "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{t.tableName}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
|
|
|
|
|
{/* 값 컬럼 / 라벨 컬럼 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-1">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">값 컬럼</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={source.valueColumn || ""}
|
|
|
|
|
onValueChange={(v) => onUpdate({ valueColumn: v })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-0.5 h-7 text-xs">
|
|
|
|
|
<SelectValue placeholder="선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{columns.map((c) => (
|
|
|
|
|
<SelectItem
|
|
|
|
|
key={c.name}
|
|
|
|
|
value={c.name}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
{c.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">라벨 컬럼</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={source.labelColumn || ""}
|
|
|
|
|
onValueChange={(v) => onUpdate({ labelColumn: v })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-0.5 h-7 text-xs">
|
|
|
|
|
<SelectValue placeholder="선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{columns.map((c) => (
|
|
|
|
|
<SelectItem
|
|
|
|
|
key={c.name}
|
|
|
|
|
value={c.name}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
{c.name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:12:22 +09:00
|
|
|
// AutoNumberEditor 삭제됨: 채번 규칙은 저장 탭 > 자동생성 필드에서 관리
|
2026-02-27 12:48:33 +09:00
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// JsonKeySelect: JSON 키 드롭다운 (자동 추출)
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
function JsonKeySelect({
|
|
|
|
|
value,
|
|
|
|
|
keys,
|
|
|
|
|
onValueChange,
|
|
|
|
|
onOpen,
|
|
|
|
|
}: {
|
|
|
|
|
value: string;
|
|
|
|
|
keys: string[];
|
|
|
|
|
onValueChange: (v: string) => void;
|
|
|
|
|
onOpen?: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
const handleOpenChange = (nextOpen: boolean) => {
|
|
|
|
|
setOpen(nextOpen);
|
|
|
|
|
if (nextOpen) onOpen?.();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (keys.length === 0 && !value) {
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="키"
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => onValueChange(e.target.value)}
|
|
|
|
|
onFocus={() => onOpen?.()}
|
|
|
|
|
className="h-7 w-24 text-xs"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
role="combobox"
|
|
|
|
|
className="h-7 w-24 justify-between text-xs"
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">{value || "키 선택"}</span>
|
|
|
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-48 p-0" align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="키 검색..." className="text-xs" />
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty className="py-2 text-center text-xs">
|
|
|
|
|
{keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."}
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{keys.map((k) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={k}
|
|
|
|
|
value={k}
|
|
|
|
|
onSelect={(v) => {
|
|
|
|
|
onValueChange(v === value ? "" : v);
|
|
|
|
|
setOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Check
|
|
|
|
|
className={cn(
|
|
|
|
|
"mr-2 h-3 w-3",
|
|
|
|
|
value === k ? "opacity-100" : "opacity-0"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{k}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
// AppearanceEditor: 섹션 외관 설정
|
|
|
|
|
// ========================================
|
|
|
|
|
|
|
|
|
|
const BG_COLOR_OPTIONS = [
|
|
|
|
|
{ value: "__default__", label: "기본" },
|
|
|
|
|
{ value: "bg-emerald-50", label: "초록" },
|
|
|
|
|
{ value: "bg-blue-50", label: "파랑" },
|
|
|
|
|
{ value: "bg-amber-50", label: "노랑" },
|
|
|
|
|
{ value: "bg-rose-50", label: "빨강" },
|
|
|
|
|
{ value: "bg-purple-50", label: "보라" },
|
|
|
|
|
{ value: "bg-gray-50", label: "회색" },
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
const BORDER_COLOR_OPTIONS = [
|
|
|
|
|
{ value: "__default__", label: "기본" },
|
|
|
|
|
{ value: "border-emerald-200", label: "초록" },
|
|
|
|
|
{ value: "border-blue-200", label: "파랑" },
|
|
|
|
|
{ value: "border-amber-200", label: "노랑" },
|
|
|
|
|
{ value: "border-rose-200", label: "빨강" },
|
|
|
|
|
{ value: "border-purple-200", label: "보라" },
|
|
|
|
|
{ value: "border-gray-200", label: "회색" },
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
function AppearanceEditor({
|
|
|
|
|
style,
|
|
|
|
|
appearance,
|
|
|
|
|
onUpdate,
|
|
|
|
|
}: {
|
|
|
|
|
style: FieldSectionStyle;
|
|
|
|
|
appearance?: FieldSectionAppearance;
|
|
|
|
|
onUpdate: (appearance: FieldSectionAppearance) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const defaults = DEFAULT_SECTION_APPEARANCES[style];
|
|
|
|
|
const current = appearance || {};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-xs text-muted-foreground">외관 설정</Label>
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">배경색</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={current.bgColor || "__default__"}
|
|
|
|
|
onValueChange={(v) => onUpdate({ ...current, bgColor: v === "__default__" ? undefined : v })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-0.5 h-7 text-xs">
|
|
|
|
|
<SelectValue placeholder={defaults.bgColor} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{BG_COLOR_OPTIONS.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
|
|
|
<span className="flex items-center gap-1.5">
|
|
|
|
|
{opt.value !== "__default__" && <span className={cn("inline-block h-3 w-3 rounded-sm border", opt.value)} />}
|
|
|
|
|
{opt.label}
|
|
|
|
|
</span>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-[10px]">테두리색</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={current.borderColor || "__default__"}
|
|
|
|
|
onValueChange={(v) => onUpdate({ ...current, borderColor: v === "__default__" ? undefined : v })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="mt-0.5 h-7 text-xs">
|
|
|
|
|
<SelectValue placeholder={defaults.borderColor} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{BORDER_COLOR_OPTIONS.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
|
|
|
|
<span className="flex items-center gap-1.5">
|
|
|
|
|
{opt.value !== "__default__" && <span className={cn("inline-block h-3 w-3 rounded-sm border-2", opt.value)} />}
|
|
|
|
|
{opt.label}
|
|
|
|
|
</span>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|