[agent-pipeline] pipe-20260317063830-0nfs round-1
This commit is contained in:
parent
80cd95e683
commit
128872b766
|
|
@ -5,28 +5,16 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
|
import { Plus, Save, ListOrdered } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||||
|
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
||||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
|
||||||
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||||
import { apiClient } from "@/lib/api/client";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface NumberingColumn {
|
|
||||||
tableName: string;
|
|
||||||
tableLabel: string;
|
|
||||||
columnName: string;
|
|
||||||
columnLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedColumns {
|
|
||||||
tableLabel: string;
|
|
||||||
columns: NumberingColumn[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NumberingRuleDesignerProps {
|
interface NumberingRuleDesignerProps {
|
||||||
initialConfig?: NumberingRuleConfig;
|
initialConfig?: NumberingRuleConfig;
|
||||||
onSave?: (config: NumberingRuleConfig) => void;
|
onSave?: (config: NumberingRuleConfig) => void;
|
||||||
|
|
@ -34,8 +22,8 @@ interface NumberingRuleDesignerProps {
|
||||||
maxRules?: number;
|
maxRules?: number;
|
||||||
isPreview?: boolean;
|
isPreview?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
currentTableName?: string;
|
||||||
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
menuObjid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
|
|
@ -48,124 +36,84 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
currentTableName,
|
currentTableName,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
}) => {
|
}) => {
|
||||||
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||||
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
||||||
|
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [columnSearch, setColumnSearch] = useState("");
|
|
||||||
const [rightTitle, setRightTitle] = useState("규칙 편집");
|
|
||||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
|
||||||
|
|
||||||
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
|
||||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
// 좌측: 채번 타입 컬럼 목록 로드
|
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
|
||||||
|
|
||||||
|
// 좌측: 규칙 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNumberingColumns();
|
loadRules();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadNumberingColumns = async () => {
|
const loadRules = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get("/table-management/numbering-columns");
|
const response = await getNumberingRules();
|
||||||
if (response.data.success && response.data.data) {
|
if (response.success && response.data) {
|
||||||
setNumberingColumns(response.data.data);
|
setRulesList(response.data);
|
||||||
|
if (response.data.length > 0 && !selectedRuleId) {
|
||||||
|
const first = response.data[0];
|
||||||
|
setSelectedRuleId(first.ruleId);
|
||||||
|
setCurrentRule(JSON.parse(JSON.stringify(first)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (e) {
|
||||||
console.error("채번 컬럼 목록 로드 실패:", error);
|
console.error("채번 규칙 목록 로드 실패:", e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
|
const handleSelectRule = (rule: NumberingRuleConfig) => {
|
||||||
const handleSelectColumn = async (tableName: string, columnName: string) => {
|
setSelectedRuleId(rule.ruleId);
|
||||||
setSelectedColumn({ tableName, columnName });
|
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
||||||
setLoading(true);
|
setSelectedPartOrder(null);
|
||||||
try {
|
|
||||||
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
|
|
||||||
if (response.data.success && response.data.data) {
|
|
||||||
const rule = response.data.data as NumberingRuleConfig;
|
|
||||||
setCurrentRule(JSON.parse(JSON.stringify(rule)));
|
|
||||||
} else {
|
|
||||||
// 규칙 없으면 신규 생성 모드
|
|
||||||
const newRule: NumberingRuleConfig = {
|
|
||||||
ruleId: `rule-${Date.now()}`,
|
|
||||||
ruleName: `${columnName} 채번`,
|
|
||||||
parts: [],
|
|
||||||
separator: "-",
|
|
||||||
resetPeriod: "none",
|
|
||||||
currentSequence: 1,
|
|
||||||
scopeType: "table",
|
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
};
|
|
||||||
setCurrentRule(newRule);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const newRule: NumberingRuleConfig = {
|
|
||||||
ruleId: `rule-${Date.now()}`,
|
|
||||||
ruleName: `${columnName} 채번`,
|
|
||||||
parts: [],
|
|
||||||
separator: "-",
|
|
||||||
resetPeriod: "none",
|
|
||||||
currentSequence: 1,
|
|
||||||
scopeType: "table",
|
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
};
|
|
||||||
setCurrentRule(newRule);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테이블별로 그룹화
|
const handleAddNewRule = () => {
|
||||||
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
|
const newRule: NumberingRuleConfig = {
|
||||||
if (!acc[col.tableName]) {
|
ruleId: `rule-${Date.now()}`,
|
||||||
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
|
ruleName: "새 규칙",
|
||||||
}
|
parts: [],
|
||||||
acc[col.tableName].columns.push(col);
|
separator: "-",
|
||||||
return acc;
|
resetPeriod: "none",
|
||||||
}, {});
|
currentSequence: 1,
|
||||||
|
scopeType: "global",
|
||||||
// 검색 필터 적용
|
tableName: currentTableName ?? "",
|
||||||
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
|
columnName: "",
|
||||||
if (!columnSearch) return true;
|
};
|
||||||
const search = columnSearch.toLowerCase();
|
setRulesList((prev) => [...prev, newRule]);
|
||||||
return (
|
setSelectedRuleId(newRule.ruleId);
|
||||||
tableName.toLowerCase().includes(search) ||
|
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
|
||||||
group.tableLabel.toLowerCase().includes(search) ||
|
setSelectedPartOrder(null);
|
||||||
group.columns.some(
|
toast.success("새 규칙이 추가되었습니다");
|
||||||
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
|
};
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentRule) {
|
if (currentRule) onChange?.(currentRule);
|
||||||
onChange?.(currentRule);
|
|
||||||
}
|
|
||||||
}, [currentRule, onChange]);
|
}, [currentRule, onChange]);
|
||||||
|
|
||||||
// currentRule이 변경될 때 파트별 구분자 상태 동기화
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentRule && currentRule.parts.length > 0) {
|
if (currentRule && currentRule.parts.length > 0) {
|
||||||
const newSepTypes: Record<number, SeparatorType> = {};
|
const newSepTypes: Record<number, SeparatorType> = {};
|
||||||
const newCustomSeps: Record<number, string> = {};
|
const newCustomSeps: Record<number, string> = {};
|
||||||
|
|
||||||
currentRule.parts.forEach((part) => {
|
currentRule.parts.forEach((part) => {
|
||||||
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
||||||
if (sep === "") {
|
if (sep === "") {
|
||||||
newSepTypes[part.order] = "none";
|
newSepTypes[part.order] = "none";
|
||||||
newCustomSeps[part.order] = "";
|
newCustomSeps[part.order] = "";
|
||||||
} else {
|
} else {
|
||||||
const predefinedOption = SEPARATOR_OPTIONS.find(
|
const opt = SEPARATOR_OPTIONS.find(
|
||||||
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
(o) => o.value !== "custom" && o.value !== "none" && o.displayValue === sep
|
||||||
);
|
);
|
||||||
if (predefinedOption) {
|
if (opt) {
|
||||||
newSepTypes[part.order] = predefinedOption.value;
|
newSepTypes[part.order] = opt.value;
|
||||||
newCustomSeps[part.order] = "";
|
newCustomSeps[part.order] = "";
|
||||||
} else {
|
} else {
|
||||||
newSepTypes[part.order] = "custom";
|
newSepTypes[part.order] = "custom";
|
||||||
|
|
@ -173,54 +121,45 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setSeparatorTypes(newSepTypes);
|
setSeparatorTypes(newSepTypes);
|
||||||
setCustomSeparators(newCustomSeps);
|
setCustomSeparators(newCustomSeps);
|
||||||
}
|
}
|
||||||
}, [currentRule?.ruleId]);
|
}, [currentRule?.ruleId]);
|
||||||
|
|
||||||
// 개별 파트 구분자 변경 핸들러
|
|
||||||
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
||||||
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
|
setSeparatorTypes((prev) => ({ ...prev, [partOrder]: type }));
|
||||||
if (type !== "custom") {
|
if (type !== "custom") {
|
||||||
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
|
const option = SEPARATOR_OPTIONS.find((opt) => opt.value === type);
|
||||||
const newSeparator = option?.displayValue ?? "";
|
const newSeparator = option?.displayValue ?? "";
|
||||||
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
|
setCustomSeparators((prev) => ({ ...prev, [partOrder]: "" }));
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
parts: prev.parts.map((part) =>
|
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: newSeparator } : p)),
|
||||||
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 개별 파트 직접 입력 구분자 변경 핸들러
|
|
||||||
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
||||||
const trimmedValue = value.slice(0, 2);
|
const trimmedValue = value.slice(0, 2);
|
||||||
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
|
setCustomSeparators((prev) => ({ ...prev, [partOrder]: trimmedValue }));
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
parts: prev.parts.map((part) =>
|
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: trimmedValue } : p)),
|
||||||
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddPart = useCallback(() => {
|
const handleAddPart = useCallback(() => {
|
||||||
if (!currentRule) return;
|
if (!currentRule) return;
|
||||||
|
|
||||||
if (currentRule.parts.length >= maxRules) {
|
if (currentRule.parts.length >= maxRules) {
|
||||||
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
|
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPart: NumberingRulePart = {
|
const newPart: NumberingRulePart = {
|
||||||
id: `part-${Date.now()}`,
|
id: `part-${Date.now()}`,
|
||||||
order: currentRule.parts.length + 1,
|
order: currentRule.parts.length + 1,
|
||||||
|
|
@ -229,40 +168,33 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
autoConfig: { textValue: "CODE" },
|
autoConfig: { textValue: "CODE" },
|
||||||
separatorAfter: "-",
|
separatorAfter: "-",
|
||||||
};
|
};
|
||||||
|
setCurrentRule((prev) => (prev ? { ...prev, parts: [...prev.parts, newPart] } : null));
|
||||||
setCurrentRule((prev) => {
|
setSeparatorTypes((prev) => ({ ...prev, [newPart.order]: "-" }));
|
||||||
if (!prev) return null;
|
setCustomSeparators((prev) => ({ ...prev, [newPart.order]: "" }));
|
||||||
return { ...prev, parts: [...prev.parts, newPart] };
|
|
||||||
});
|
|
||||||
|
|
||||||
// 새 파트의 구분자 상태 초기화
|
|
||||||
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
|
|
||||||
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
|
|
||||||
|
|
||||||
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||||
}, [currentRule, maxRules]);
|
}, [currentRule, maxRules]);
|
||||||
|
|
||||||
// partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용)
|
|
||||||
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
|
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
parts: prev.parts.map((part) => (part.order === partOrder ? { ...part, ...updates } : part)),
|
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, ...updates } : p)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// partOrder 기반으로 파트 삭제 (id가 null일 수 있으므로 order 사용)
|
|
||||||
const handleDeletePart = useCallback((partOrder: number) => {
|
const handleDeletePart = useCallback((partOrder: number) => {
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
parts: prev.parts.filter((part) => part.order !== partOrder).map((part, index) => ({ ...part, order: index + 1 })),
|
parts: prev.parts
|
||||||
|
.filter((p) => p.order !== partOrder)
|
||||||
|
.map((p, i) => ({ ...p, order: i + 1 })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
setSelectedPartOrder(null);
|
||||||
toast.success("규칙이 삭제되었습니다");
|
toast.success("규칙이 삭제되었습니다");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -271,246 +203,282 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
toast.error("저장할 규칙이 없습니다");
|
toast.error("저장할 규칙이 없습니다");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentRule.parts.length === 0) {
|
if (currentRule.parts.length === 0) {
|
||||||
toast.error("최소 1개 이상의 규칙을 추가해주세요");
|
toast.error("최소 1개 이상의 규칙을 추가해주세요");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 파트별 기본 autoConfig 정의
|
|
||||||
const defaultAutoConfigs: Record<string, any> = {
|
const defaultAutoConfigs: Record<string, any> = {
|
||||||
sequence: { sequenceLength: 3, startFrom: 1 },
|
sequence: { sequenceLength: 3, startFrom: 1 },
|
||||||
number: { numberLength: 4, numberValue: 1 },
|
number: { numberLength: 4, numberValue: 1 },
|
||||||
date: { dateFormat: "YYYYMMDD" },
|
date: { dateFormat: "YYYYMMDD" },
|
||||||
text: { textValue: "" },
|
text: { textValue: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
|
|
||||||
const partsWithDefaults = currentRule.parts.map((part) => {
|
const partsWithDefaults = currentRule.parts.map((part) => {
|
||||||
if (part.generationMethod === "auto") {
|
if (part.generationMethod === "auto") {
|
||||||
const defaults = defaultAutoConfigs[part.partType] || {};
|
const defaults = defaultAutoConfigs[part.partType] || {};
|
||||||
return {
|
return { ...part, autoConfig: { ...defaults, ...part.autoConfig } };
|
||||||
...part,
|
|
||||||
autoConfig: { ...defaults, ...part.autoConfig },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return part;
|
return part;
|
||||||
});
|
});
|
||||||
|
|
||||||
const ruleToSave = {
|
const ruleToSave = {
|
||||||
...currentRule,
|
...currentRule,
|
||||||
parts: partsWithDefaults,
|
parts: partsWithDefaults,
|
||||||
scopeType: "table" as const,
|
scopeType: "global" as const,
|
||||||
tableName: selectedColumn?.tableName || currentRule.tableName || "",
|
tableName: currentRule.tableName || currentTableName || "",
|
||||||
columnName: selectedColumn?.columnName || currentRule.columnName || "",
|
columnName: currentRule.columnName || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테스트 테이블에 저장 (numbering_rules)
|
|
||||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
|
||||||
setCurrentRule(currentData);
|
setCurrentRule(saved);
|
||||||
|
setRulesList((prev) => {
|
||||||
|
const idx = prev.findIndex((r) => r.ruleId === currentRule.ruleId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = saved;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [...prev, saved];
|
||||||
|
});
|
||||||
|
setSelectedRuleId(saved.ruleId);
|
||||||
await onSave?.(response.data);
|
await onSave?.(response.data);
|
||||||
toast.success("채번 규칙이 저장되었습니다");
|
toast.success("채번 규칙이 저장되었습니다");
|
||||||
} else {
|
} else {
|
||||||
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
|
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, {
|
||||||
|
guidance: "설정을 확인하고 다시 시도해 주세요.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
|
showErrorToast("채번 규칙 저장에 실패했습니다", error, {
|
||||||
|
guidance: "설정을 확인하고 다시 시도해 주세요.",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentRule, onSave, selectedColumn]);
|
}, [currentRule, onSave, currentTableName]);
|
||||||
|
|
||||||
|
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
|
||||||
|
const globalSep = currentRule?.separator ?? "-";
|
||||||
|
const partItems = currentRule ? computePartDisplayItems(currentRule) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex h-full gap-4 ${className}`}>
|
<div className={cn("flex h-full gap-4", className)}>
|
||||||
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
|
{/* 좌측: 규칙 리스트 (code-nav, 220px) */}
|
||||||
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
|
<div className="code-nav flex w-[220px] flex-shrink-0 flex-col gap-3">
|
||||||
<h2 className="text-sm font-semibold sm:text-base">채번 컬럼</h2>
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<ListOrdered className="h-4 w-4 text-muted-foreground" />
|
||||||
value={columnSearch}
|
<span className="text-sm font-semibold">채번 규칙 ({rulesList.length})</span>
|
||||||
onChange={(e) => setColumnSearch(e.target.value)}
|
</div>
|
||||||
placeholder="검색..."
|
<Button
|
||||||
className="h-8 text-xs"
|
size="sm"
|
||||||
/>
|
className="h-8 gap-1 text-xs font-medium"
|
||||||
|
onClick={handleAddNewRule}
|
||||||
<div className="flex-1 space-y-1 overflow-y-auto">
|
disabled={isPreview || loading}
|
||||||
{loading && numberingColumns.length === 0 ? (
|
>
|
||||||
<div className="flex h-32 items-center justify-center">
|
<Plus className="h-3.5 w-3.5" />
|
||||||
<p className="text-muted-foreground text-xs">로딩 중...</p>
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-0.5 overflow-y-auto">
|
||||||
|
{loading && rulesList.length === 0 ? (
|
||||||
|
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
</div>
|
</div>
|
||||||
) : filteredGroups.length === 0 ? (
|
) : rulesList.length === 0 ? (
|
||||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50 text-xs text-muted-foreground">
|
||||||
<p className="text-muted-foreground text-xs">
|
규칙이 없습니다
|
||||||
{numberingColumns.length === 0
|
|
||||||
? "채번 타입 컬럼이 없습니다"
|
|
||||||
: "검색 결과가 없습니다"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredGroups.map(([tableName, group]) => (
|
rulesList.map((rule) => {
|
||||||
<div key={tableName} className="mb-2">
|
const isSelected = selectedRuleId === rule.ruleId;
|
||||||
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
|
return (
|
||||||
<FolderTree className="h-3 w-3" />
|
<button
|
||||||
<span>{group.tableLabel}</span>
|
key={rule.ruleId}
|
||||||
<span className="text-muted-foreground/60">({group.columns.length})</span>
|
type="button"
|
||||||
</div>
|
className={cn(
|
||||||
{group.columns.map((col) => {
|
"code-nav-item flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left transition-colors",
|
||||||
const isSelected =
|
isSelected
|
||||||
selectedColumn?.tableName === col.tableName &&
|
? "border-l-[3px] border-primary bg-primary/5 font-bold"
|
||||||
selectedColumn?.columnName === col.columnName;
|
: "hover:bg-accent"
|
||||||
return (
|
)}
|
||||||
<div
|
onClick={() => handleSelectRule(rule)}
|
||||||
key={`${col.tableName}.${col.columnName}`}
|
>
|
||||||
className={cn(
|
<span className="rule-name min-w-0 truncate text-xs">{rule.ruleName}</span>
|
||||||
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
|
<span className="rule-table text-[9px] text-muted-foreground">
|
||||||
isSelected
|
{rule.tableName || "-"}
|
||||||
? "bg-primary/10 text-primary border-primary border font-medium"
|
</span>
|
||||||
: "hover:bg-accent"
|
<span className="mt-0.5 inline-flex">
|
||||||
)}
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
{rule.parts?.length ?? 0}개
|
||||||
>
|
</span>
|
||||||
{col.columnLabel}
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 구분선 */}
|
<div className="h-full w-px flex-shrink-0 bg-border" />
|
||||||
<div className="bg-border h-full w-px"></div>
|
|
||||||
|
|
||||||
{/* 우측: 편집 영역 */}
|
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */}
|
||||||
<div className="flex flex-1 flex-col gap-4">
|
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||||
{!currentRule ? (
|
{!currentRule ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||||
<div className="text-center">
|
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||||
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
<p className="mb-2 text-lg font-medium text-muted-foreground">규칙을 선택하세요</p>
|
||||||
<p className="text-muted-foreground mb-2 text-lg font-medium">컬럼을 선택해주세요</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-muted-foreground text-sm">좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다</p>
|
좌측에서 채번 규칙을 선택하거나 "추가"로 새 규칙을 만드세요
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2">
|
||||||
{editingRightTitle ? (
|
<Label className="text-xs font-medium">규칙명</Label>
|
||||||
<Input
|
<Input
|
||||||
value={rightTitle}
|
value={currentRule.ruleName}
|
||||||
onChange={(e) => setRightTitle(e.target.value)}
|
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
|
||||||
onBlur={() => setEditingRightTitle(false)}
|
placeholder="예: 프로젝트 코드"
|
||||||
onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)}
|
className="h-9 text-sm"
|
||||||
className="h-8 text-sm font-semibold"
|
/>
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<h2 className="text-sm font-semibold sm:text-base">{rightTitle}</h2>
|
|
||||||
)}
|
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingRightTitle(true)}>
|
|
||||||
<Edit2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* 큰 미리보기 스트립 (code-preview-strip) */}
|
||||||
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
|
<div className="code-preview-strip flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<NumberingRulePreview config={currentRule} variant="strip" />
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Label className="text-sm font-medium">규칙명</Label>
|
|
||||||
<Input
|
|
||||||
value={currentRule.ruleName}
|
|
||||||
onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))}
|
|
||||||
className="h-9"
|
|
||||||
placeholder="예: 프로젝트 코드"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Label className="text-sm font-medium">미리보기</Label>
|
|
||||||
<NumberingRulePreview config={currentRule} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
{/* 파이프라인 영역 (code-pipeline-area) */}
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="code-pipeline-area flex flex-col gap-2">
|
||||||
<h3 className="text-sm font-semibold">코드 구성</h3>
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-xs font-semibold text-muted-foreground">코드 구성</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
{currentRule.parts.length}/{maxRules}
|
{currentRule.parts.length}/{maxRules}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-2 overflow-x-auto overflow-y-hidden py-1">
|
||||||
{currentRule.parts.length === 0 ? (
|
{currentRule.parts.length === 0 ? (
|
||||||
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
<div className="flex h-24 min-w-[200px] items-center justify-center rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground">
|
||||||
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
규칙을 추가하여 코드를 구성하세요
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-stretch gap-3">
|
<>
|
||||||
{currentRule.parts.map((part, index) => (
|
{currentRule.parts.map((part, index) => {
|
||||||
<React.Fragment key={`part-${part.order}-${index}`}>
|
const item = partItems.find((i) => i.order === part.order);
|
||||||
<div className="flex w-[200px] flex-col">
|
const sep = part.separatorAfter ?? globalSep;
|
||||||
<NumberingRuleCard
|
const isSelected = selectedPartOrder === part.order;
|
||||||
part={part}
|
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
|
||||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
return (
|
||||||
onDelete={() => handleDeletePart(part.order)}
|
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||||
isPreview={isPreview}
|
<button
|
||||||
tableName={selectedColumn?.tableName}
|
type="button"
|
||||||
/>
|
className={cn(
|
||||||
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
"pipe-segment min-w-[120px] rounded-[10px] border-2 px-3 py-3 text-left transition-all",
|
||||||
{index < currentRule.parts.length - 1 && (
|
part.partType === "date" && "border-warning",
|
||||||
<div className="mt-2 flex items-center gap-1">
|
part.partType === "text" && "border-primary",
|
||||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">뒤 구분자</span>
|
part.partType === "sequence" && "border-primary",
|
||||||
<Select
|
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
|
||||||
value={separatorTypes[part.order] || "-"}
|
isSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
|
||||||
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-6 flex-1 text-[10px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SEPARATOR_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{separatorTypes[part.order] === "custom" && (
|
|
||||||
<Input
|
|
||||||
value={customSeparators[part.order] || ""}
|
|
||||||
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
|
|
||||||
className="h-6 w-14 text-center text-[10px]"
|
|
||||||
placeholder="2자"
|
|
||||||
maxLength={2}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
onClick={() => setSelectedPartOrder(part.order)}
|
||||||
)}
|
>
|
||||||
</div>
|
<div className="text-[10px] font-medium text-muted-foreground">{typeLabel}</div>
|
||||||
</React.Fragment>
|
<div className={cn("mt-0.5 truncate font-mono text-sm font-medium", getPartTypeColorClass(part.partType))}>
|
||||||
))}
|
{item?.displayValue ?? "-"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
|
{index < currentRule.parts.length - 1 && (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<span className="text-xs">→</span>
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono">
|
||||||
|
{sep || "(없음)"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-[52px] w-10 flex-shrink-0 items-center justify-center rounded-full border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
|
||||||
|
onClick={handleAddPart}
|
||||||
|
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
|
||||||
|
aria-label="규칙 추가"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */}
|
||||||
|
{selectedPart && (
|
||||||
|
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto rounded-lg bg-muted/30 p-4">
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
||||||
|
<NumberingRuleCard
|
||||||
|
part={selectedPart}
|
||||||
|
onUpdate={(updates) => handleUpdatePart(selectedPart.order, updates)}
|
||||||
|
onDelete={() => handleDeletePart(selectedPart.order)}
|
||||||
|
isPreview={isPreview}
|
||||||
|
tableName={currentRule.tableName ?? currentTableName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{currentRule.parts.some((p) => p.order === selectedPart.order) && (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<span className="text-[10px] text-muted-foreground">뒤 구분자</span>
|
||||||
|
<Select
|
||||||
|
value={separatorTypes[selectedPart.order] ?? "-"}
|
||||||
|
onValueChange={(v) => handlePartSeparatorChange(selectedPart.order, v as SeparatorType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-24 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SEPARATOR_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{separatorTypes[selectedPart.order] === "custom" && (
|
||||||
|
<Input
|
||||||
|
value={customSeparators[selectedPart.order] ?? ""}
|
||||||
|
onChange={(e) => handlePartCustomSeparatorChange(selectedPart.order, e.target.value)}
|
||||||
|
className="h-7 w-14 text-center text-[10px]"
|
||||||
|
placeholder="2자"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 저장 바 (code-save-bar) */}
|
||||||
|
<div className="code-save-bar flex flex-shrink-0 items-center justify-between gap-4 border-t border-border pt-4">
|
||||||
|
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||||
|
{currentRule.tableName && (
|
||||||
|
<span>테이블: {currentRule.tableName}</span>
|
||||||
|
)}
|
||||||
|
{currentRule.columnName && (
|
||||||
|
<span className="ml-2">컬럼: {currentRule.columnName}</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-2">구분자: {globalSep || "-"}</span>
|
||||||
|
{currentRule.resetPeriod && currentRule.resetPeriod !== "none" && (
|
||||||
|
<span className="ml-2">리셋: {currentRule.resetPeriod}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAddPart}
|
onClick={handleSave}
|
||||||
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
|
disabled={isPreview || loading}
|
||||||
variant="outline"
|
className="h-9 gap-2 text-sm font-medium"
|
||||||
className="h-9 flex-1 text-sm"
|
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
규칙 추가
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={isPreview || loading} className="h-9 flex-1 text-sm">
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
{loading ? "저장 중..." : "저장"}
|
{loading ? "저장 중..." : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,162 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
import { NumberingRuleConfig, NumberingRulePart, CodePartType } from "@/types/numbering-rule";
|
||||||
|
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
|
||||||
|
|
||||||
|
/** 파트별 표시값 + 타입 (미리보기 스트립/세그먼트용) */
|
||||||
|
export interface PartDisplayItem {
|
||||||
|
partType: CodePartType;
|
||||||
|
displayValue: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** config에서 파트별 표시값 배열 계산 (정렬된 parts 기준) */
|
||||||
|
export function computePartDisplayItems(config: NumberingRuleConfig): PartDisplayItem[] {
|
||||||
|
if (!config.parts || config.parts.length === 0) return [];
|
||||||
|
const sorted = [...config.parts].sort((a, b) => a.order - b.order);
|
||||||
|
const globalSep = config.separator ?? "-";
|
||||||
|
return sorted.map((part) => ({
|
||||||
|
order: part.order,
|
||||||
|
partType: part.partType,
|
||||||
|
displayValue: getPartDisplayValue(part),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartDisplayValue(part: NumberingRulePart): string {
|
||||||
|
if (part.generationMethod === "manual") {
|
||||||
|
return part.manualConfig?.value || "XXX";
|
||||||
|
}
|
||||||
|
const c = part.autoConfig || {};
|
||||||
|
switch (part.partType) {
|
||||||
|
case "sequence":
|
||||||
|
return String(c.startFrom ?? 1).padStart(c.sequenceLength ?? 3, "0");
|
||||||
|
case "number":
|
||||||
|
return String(c.numberValue ?? 0).padStart(c.numberLength ?? 4, "0");
|
||||||
|
case "date": {
|
||||||
|
const format = c.dateFormat || "YYYYMMDD";
|
||||||
|
if (c.useColumnValue && c.sourceColumnName) {
|
||||||
|
return format === "YYYY" ? "[YYYY]" : format === "YY" ? "[YY]" : format === "YYYYMM" ? "[YYYYMM]" : format === "YYMM" ? "[YYMM]" : format === "YYMMDD" ? "[YYMMDD]" : "[DATE]";
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(now.getDate()).padStart(2, "0");
|
||||||
|
if (format === "YYYY") return String(y);
|
||||||
|
if (format === "YY") return String(y).slice(-2);
|
||||||
|
if (format === "YYYYMM") return `${y}${m}`;
|
||||||
|
if (format === "YYMM") return `${String(y).slice(-2)}${m}`;
|
||||||
|
if (format === "YYYYMMDD") return `${y}${m}${d}`;
|
||||||
|
if (format === "YYMMDD") return `${String(y).slice(-2)}${m}${d}`;
|
||||||
|
return `${y}${m}${d}`;
|
||||||
|
}
|
||||||
|
case "text":
|
||||||
|
return c.textValue || "TEXT";
|
||||||
|
default:
|
||||||
|
return "XXX";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 파트 타입별 미리보기용 텍스트 색상 클래스 (CSS 변수 기반) */
|
||||||
|
export function getPartTypeColorClass(partType: CodePartType): string {
|
||||||
|
switch (partType) {
|
||||||
|
case "date":
|
||||||
|
return "text-warning";
|
||||||
|
case "text":
|
||||||
|
return "text-primary";
|
||||||
|
case "sequence":
|
||||||
|
return "text-primary";
|
||||||
|
case "number":
|
||||||
|
return "text-muted-foreground";
|
||||||
|
case "category":
|
||||||
|
case "reference":
|
||||||
|
return "text-muted-foreground";
|
||||||
|
default:
|
||||||
|
return "text-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 파트 타입별 점(dot) 배경 색상 (범례용) */
|
||||||
|
export function getPartTypeDotClass(partType: CodePartType): string {
|
||||||
|
switch (partType) {
|
||||||
|
case "date":
|
||||||
|
return "bg-warning";
|
||||||
|
case "text":
|
||||||
|
case "sequence":
|
||||||
|
return "bg-primary";
|
||||||
|
case "number":
|
||||||
|
case "category":
|
||||||
|
case "reference":
|
||||||
|
return "bg-muted-foreground";
|
||||||
|
default:
|
||||||
|
return "bg-foreground";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface NumberingRulePreviewProps {
|
interface NumberingRulePreviewProps {
|
||||||
config: NumberingRuleConfig;
|
config: NumberingRuleConfig;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** 큰 미리보기 스트립: 28px, 파트별 색상, 하단 범례 */
|
||||||
|
variant?: "default" | "strip";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
config,
|
config,
|
||||||
compact = false
|
compact = false,
|
||||||
|
variant = "default",
|
||||||
}) => {
|
}) => {
|
||||||
|
const partItems = useMemo(() => computePartDisplayItems(config), [config]);
|
||||||
|
const sortedParts = useMemo(
|
||||||
|
() => (config.parts ? [...config.parts].sort((a, b) => a.order - b.order) : []),
|
||||||
|
[config.parts]
|
||||||
|
);
|
||||||
const generatedCode = useMemo(() => {
|
const generatedCode = useMemo(() => {
|
||||||
if (!config.parts || config.parts.length === 0) {
|
if (partItems.length === 0) return "규칙을 추가해주세요";
|
||||||
return "규칙을 추가해주세요";
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedParts = config.parts.sort((a, b) => a.order - b.order);
|
|
||||||
|
|
||||||
const partValues = sortedParts.map((part) => {
|
|
||||||
if (part.generationMethod === "manual") {
|
|
||||||
return part.manualConfig?.value || "XXX";
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoConfig = part.autoConfig || {};
|
|
||||||
|
|
||||||
switch (part.partType) {
|
|
||||||
case "sequence": {
|
|
||||||
const length = autoConfig.sequenceLength || 3;
|
|
||||||
const startFrom = autoConfig.startFrom || 1;
|
|
||||||
return String(startFrom).padStart(length, "0");
|
|
||||||
}
|
|
||||||
case "number": {
|
|
||||||
const length = autoConfig.numberLength || 4;
|
|
||||||
const value = autoConfig.numberValue || 0;
|
|
||||||
return String(value).padStart(length, "0");
|
|
||||||
}
|
|
||||||
case "date": {
|
|
||||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
|
||||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
|
||||||
switch (format) {
|
|
||||||
case "YYYY": return "[YYYY]";
|
|
||||||
case "YY": return "[YY]";
|
|
||||||
case "YYYYMM": return "[YYYYMM]";
|
|
||||||
case "YYMM": return "[YYMM]";
|
|
||||||
case "YYYYMMDD": return "[YYYYMMDD]";
|
|
||||||
case "YYMMDD": return "[YYMMDD]";
|
|
||||||
default: return "[DATE]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
|
||||||
switch (format) {
|
|
||||||
case "YYYY": return String(year);
|
|
||||||
case "YY": return String(year).slice(-2);
|
|
||||||
case "YYYYMM": return `${year}${month}`;
|
|
||||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
|
||||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
|
||||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
|
||||||
default: return `${year}${month}${day}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "text":
|
|
||||||
return autoConfig.textValue || "TEXT";
|
|
||||||
default:
|
|
||||||
return "XXX";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 파트별 개별 구분자로 결합
|
|
||||||
const globalSep = config.separator ?? "-";
|
const globalSep = config.separator ?? "-";
|
||||||
let result = "";
|
let result = "";
|
||||||
partValues.forEach((val, idx) => {
|
partItems.forEach((item, idx) => {
|
||||||
result += val;
|
result += item.displayValue;
|
||||||
if (idx < partValues.length - 1) {
|
if (idx < partItems.length - 1) {
|
||||||
const sep = sortedParts[idx].separatorAfter ?? globalSep;
|
const part = sortedParts.find((p) => p.order === item.order);
|
||||||
result += sep;
|
result += part?.separatorAfter ?? globalSep;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}, [config]);
|
}, [config.separator, partItems, sortedParts]);
|
||||||
|
|
||||||
|
if (variant === "strip") {
|
||||||
|
const globalSep = config.separator ?? "-";
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-gradient-to-b from-muted to-card px-4 py-4">
|
||||||
|
<div className="font-mono text-[28px] font-extrabold tracking-tight">
|
||||||
|
{partItems.length === 0 ? (
|
||||||
|
<span className="text-muted-foreground">규칙을 추가해주세요</span>
|
||||||
|
) : (
|
||||||
|
partItems.map((item, idx) => (
|
||||||
|
<React.Fragment key={item.order}>
|
||||||
|
<span className={getPartTypeColorClass(item.partType)}>{item.displayValue}</span>
|
||||||
|
{idx < partItems.length - 1 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{sortedParts.find((p) => p.order === item.order)?.separatorAfter ?? globalSep}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{partItems.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{CODE_PART_TYPE_OPTIONS.filter((opt) => partItems.some((p) => p.partType === opt.value)).map((opt) => (
|
||||||
|
<span key={opt.value} className="flex items-center gap-1.5">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${getPartTypeDotClass(opt.value)}`} />
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
|
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface CategoryColumn {
|
export interface CategoryColumn {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
tableLabel?: string; // 테이블 라벨 추가
|
tableLabel?: string;
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
inputType: string;
|
inputType: string;
|
||||||
|
|
@ -16,17 +16,30 @@ interface CategoryColumn {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryColumnListProps {
|
interface CategoryColumnListProps {
|
||||||
tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시)
|
tableName: string;
|
||||||
selectedColumn: string | null;
|
selectedColumn: string | null;
|
||||||
onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void;
|
onColumnSelect: (uniqueKeyOrColumnName: string, columnLabel: string, tableName: string) => void;
|
||||||
menuObjid?: number; // 현재 메뉴 OBJID (필수)
|
menuObjid?: number;
|
||||||
|
/** 대시보드 모드: 테이블 단위 네비만 표시, 선택 시 onTableSelect 호출 */
|
||||||
|
selectedTable?: string | null;
|
||||||
|
onTableSelect?: (tableName: string) => void;
|
||||||
|
/** 컬럼 로드 완료 시 부모에 전달 (Stat Strip 등 계산용) */
|
||||||
|
onColumnsLoaded?: (columns: CategoryColumn[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 컬럼 목록 (좌측 패널)
|
* 카테고리 컬럼 목록 (좌측 패널)
|
||||||
* - 형제 메뉴들의 모든 카테고리 타입 컬럼을 표시 (메뉴 스코프)
|
* - 형제 메뉴들의 모든 카테고리 타입 컬럼을 표시 (메뉴 스코프)
|
||||||
*/
|
*/
|
||||||
export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) {
|
export function CategoryColumnList({
|
||||||
|
tableName,
|
||||||
|
selectedColumn,
|
||||||
|
onColumnSelect,
|
||||||
|
menuObjid,
|
||||||
|
selectedTable = null,
|
||||||
|
onTableSelect,
|
||||||
|
onColumnsLoaded,
|
||||||
|
}: CategoryColumnListProps) {
|
||||||
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
@ -151,8 +164,8 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
);
|
);
|
||||||
|
|
||||||
setColumns(columnsWithCount);
|
setColumns(columnsWithCount);
|
||||||
|
onColumnsLoaded?.(columnsWithCount);
|
||||||
|
|
||||||
// 첫 번째 컬럼 자동 선택
|
|
||||||
if (columnsWithCount.length > 0 && !selectedColumn) {
|
if (columnsWithCount.length > 0 && !selectedColumn) {
|
||||||
const firstCol = columnsWithCount[0];
|
const firstCol = columnsWithCount[0];
|
||||||
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
||||||
|
|
@ -160,6 +173,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
|
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
|
||||||
setColumns([]);
|
setColumns([]);
|
||||||
|
onColumnsLoaded?.([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -248,21 +262,20 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
}
|
}
|
||||||
|
|
||||||
setColumns(columnsWithCount);
|
setColumns(columnsWithCount);
|
||||||
|
onColumnsLoaded?.(columnsWithCount);
|
||||||
|
|
||||||
// 첫 번째 컬럼 자동 선택
|
|
||||||
if (columnsWithCount.length > 0 && !selectedColumn) {
|
if (columnsWithCount.length > 0 && !selectedColumn) {
|
||||||
const firstCol = columnsWithCount[0];
|
const firstCol = columnsWithCount[0];
|
||||||
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
||||||
// 에러 시에도 tableName 기반으로 fallback
|
|
||||||
if (tableName) {
|
if (tableName) {
|
||||||
console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName);
|
|
||||||
await loadCategoryColumnsByTable();
|
await loadCategoryColumnsByTable();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
setColumns([]);
|
setColumns([]);
|
||||||
|
onColumnsLoaded?.([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -291,6 +304,72 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 대시보드 모드: 테이블 단위 네비만 표시
|
||||||
|
if (onTableSelect != null) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b p-2.5">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="테이블 검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8 border-0 bg-transparent pl-8 pr-8 text-xs shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-0 overflow-y-auto">
|
||||||
|
{filteredColumns.length === 0 && searchQuery ? (
|
||||||
|
<div className="text-muted-foreground py-4 text-center text-xs">
|
||||||
|
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{groupedColumns.map((group) => {
|
||||||
|
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
|
||||||
|
const isActive = selectedTable === group.tableName;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.tableName}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTableSelect(group.tableName)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-l-[3px] border-primary bg-primary/5 font-bold text-primary"
|
||||||
|
: "hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[5px] bg-primary/20 text-primary"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<FolderTree className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||||
|
{group.tableLabel || group.tableName}
|
||||||
|
</span>
|
||||||
|
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold">
|
||||||
|
{group.columns.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -298,7 +377,6 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
<p className="text-muted-foreground text-xs">관리할 카테고리 컬럼을 선택하세요</p>
|
<p className="text-muted-foreground text-xs">관리할 카테고리 컬럼을 선택하세요</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 입력 필드 */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -310,6 +388,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setSearchQuery("")}
|
onClick={() => setSearchQuery("")}
|
||||||
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
|
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -114,13 +114,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 깊이별 아이콘
|
// 깊이별 아이콘 (대/중분류 = Folder, 소분류 = Tag)
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
return isExpanded ? (
|
return isExpanded ? (
|
||||||
<FolderOpen className="h-4 w-4 text-amber-500" />
|
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Folder className="h-4 w-4 text-amber-500" />
|
<Folder className="text-muted-foreground h-4 w-4" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <Tag className="h-4 w-4 text-primary" />;
|
return <Tag className="h-4 w-4 text-primary" />;
|
||||||
|
|
@ -141,31 +141,28 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="mb-px">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
|
"group flex cursor-pointer items-center gap-[5px] rounded-[6px] px-2 py-[5px] transition-colors",
|
||||||
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
|
isSelected ? "border-primary border-l-2 bg-primary/10" : "hover:bg-muted/50",
|
||||||
isChecked && "bg-primary/5",
|
isChecked && "bg-primary/5",
|
||||||
"cursor-pointer",
|
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
||||||
onClick={() => onSelect(node)}
|
onClick={() => onSelect(node)}
|
||||||
>
|
>
|
||||||
{/* 체크박스 */}
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
onCheck(node.valueId, checked as boolean);
|
onCheck(node.valueId, checked as boolean);
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="mr-1"
|
className="mr-1 shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 확장 토글 */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded"
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-muted"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
|
|
@ -184,22 +181,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 아이콘 */}
|
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
|
|
||||||
{/* 라벨 */}
|
<div className="flex min-w-0 flex-1 items-center gap-[5px]">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<span className={cn("truncate text-sm", node.depth === 1 && "font-medium")}>
|
||||||
<span className={cn("text-sm", node.depth === 1 && "font-medium")}>{node.valueLabel}</span>
|
{node.valueLabel}
|
||||||
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">{getDepthLabel()}</span>
|
</span>
|
||||||
|
<span className="bg-muted text-muted-foreground shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-bold">
|
||||||
|
{getDepthLabel()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 비활성 표시 */}
|
|
||||||
{!node.isActive && (
|
{!node.isActive && (
|
||||||
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-[10px]">비활성</span>
|
<span className="bg-destructive/5 text-destructive shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-bold">
|
||||||
|
비활성
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
||||||
{canAddChild && (
|
{canAddChild && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,19 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 카테고리 관리 컴포넌트
|
* V2 카테고리 관리 컴포넌트
|
||||||
* - 트리 구조 기반 카테고리 값 관리
|
* - 대시보드 레이아웃: Stat Strip + 좌측 테이블 nav + 칩 바 + 트리/목록 편집기
|
||||||
* - 3단계 계층 구조 지원 (대분류/중분류/소분류)
|
* - 3단계 계층 구조 지원 (대분류/중분류/소분류)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback, useMemo } from "react";
|
||||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||||
|
import type { CategoryColumn } from "@/components/table-category/CategoryColumnList";
|
||||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||||
import { LayoutList, TreeDeciduous } from "lucide-react";
|
import { LayoutList, TreeDeciduous } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
|
|
||||||
import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types";
|
import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types";
|
||||||
|
|
||||||
interface V2CategoryManagerComponentProps {
|
interface V2CategoryManagerComponentProps {
|
||||||
|
|
@ -33,53 +34,62 @@ export function V2CategoryManagerComponent({
|
||||||
componentConfig,
|
componentConfig,
|
||||||
...props
|
...props
|
||||||
}: V2CategoryManagerComponentProps) {
|
}: V2CategoryManagerComponentProps) {
|
||||||
// 설정 병합 (componentConfig도 포함)
|
|
||||||
const config: V2CategoryManagerConfig = {
|
const config: V2CategoryManagerConfig = {
|
||||||
...defaultV2CategoryManagerConfig,
|
...defaultV2CategoryManagerConfig,
|
||||||
...externalConfig,
|
...externalConfig,
|
||||||
...componentConfig,
|
...componentConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// tableName 우선순위: props > selectedScreen > componentConfig
|
const effectiveTableName =
|
||||||
const effectiveTableName = tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || "";
|
tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || "";
|
||||||
|
|
||||||
// menuObjid 우선순위: props > selectedScreen
|
|
||||||
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
|
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
|
||||||
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
|
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
|
||||||
|
|
||||||
// 디버그 로그
|
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||||
useEffect(() => {
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||||
console.log("🔍 V2CategoryManagerComponent props:", {
|
|
||||||
tableName,
|
|
||||||
menuObjid,
|
|
||||||
selectedScreen,
|
|
||||||
effectiveTableName,
|
|
||||||
effectiveMenuObjid,
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
}, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]);
|
|
||||||
|
|
||||||
// 선택된 컬럼 상태
|
|
||||||
const [selectedColumn, setSelectedColumn] = useState<{
|
const [selectedColumn, setSelectedColumn] = useState<{
|
||||||
uniqueKey: string;
|
uniqueKey: string;
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// 뷰 모드 상태
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
|
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
|
||||||
|
|
||||||
// 컬럼 선택 핸들러
|
const handleColumnsLoaded = useCallback((loaded: CategoryColumn[]) => {
|
||||||
const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => {
|
setColumns(loaded);
|
||||||
const columnName = uniqueKey.split(".")[1];
|
if (loaded.length > 0) {
|
||||||
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
|
setSelectedTable((prev) => prev ?? loaded[0].tableName);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 우측 패널 콘텐츠
|
const handleTableSelect = useCallback((tableName: string) => {
|
||||||
|
setSelectedTable(tableName);
|
||||||
|
setSelectedColumn(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleColumnSelect = useCallback(
|
||||||
|
(uniqueKey: string, columnLabel: string, colTableName: string) => {
|
||||||
|
const columnName = uniqueKey.includes(".") ? uniqueKey.split(".")[1] : uniqueKey;
|
||||||
|
setSelectedColumn({ uniqueKey: uniqueKey.includes(".") ? uniqueKey : `${colTableName}.${uniqueKey}`, columnName, columnLabel, tableName: colTableName });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const columnCount = columns.length;
|
||||||
|
const totalValues = columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
|
||||||
|
const tableCount = new Set(columns.map((c) => c.tableName)).size;
|
||||||
|
const inactiveCount = 0;
|
||||||
|
return { columnCount, totalValues, tableCount, inactiveCount };
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
const columnsForSelectedTable = useMemo(
|
||||||
|
() => (selectedTable ? columns.filter((c) => c.tableName === selectedTable) : []),
|
||||||
|
[columns, selectedTable],
|
||||||
|
);
|
||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<>
|
<>
|
||||||
{/* 뷰 모드 토글 */}
|
|
||||||
{config.showViewModeToggle && (
|
{config.showViewModeToggle && (
|
||||||
<div className="mb-2 flex items-center justify-end gap-1">
|
<div className="mb-2 flex items-center justify-end gap-1">
|
||||||
<span className="text-muted-foreground mr-2 text-xs">보기 방식:</span>
|
<span className="text-muted-foreground mr-2 text-xs">보기 방식:</span>
|
||||||
|
|
@ -105,8 +115,6 @@ export function V2CategoryManagerComponent({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 카테고리 값 관리 */}
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{selectedColumn ? (
|
{selectedColumn ? (
|
||||||
viewMode === "tree" ? (
|
viewMode === "tree" ? (
|
||||||
|
|
@ -130,7 +138,9 @@ export function V2CategoryManagerComponent({
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
|
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"}
|
{config.showColumnList
|
||||||
|
? "칩에서 카테고리 컬럼을 선택하세요"
|
||||||
|
: "카테고리 컬럼이 설정되지 않았습니다"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -148,24 +158,107 @@ export function V2CategoryManagerComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveSplitPanel
|
<div
|
||||||
left={
|
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||||
<CategoryColumnList
|
style={{ height: config.height }}
|
||||||
tableName={effectiveTableName}
|
>
|
||||||
selectedColumn={selectedColumn?.uniqueKey || null}
|
{/* Stat Strip */}
|
||||||
onColumnSelect={handleColumnSelect}
|
<div className="grid grid-cols-4 border-b bg-background">
|
||||||
menuObjid={effectiveMenuObjid}
|
<div className="border-r py-3.5 text-center last:border-r-0">
|
||||||
/>
|
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
|
||||||
}
|
{stats.columnCount}
|
||||||
right={rightContent}
|
</div>
|
||||||
leftTitle="카테고리 컬럼"
|
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
leftWidth={config.leftPanelWidth}
|
카테고리 컬럼
|
||||||
minLeftWidth={10}
|
</div>
|
||||||
maxLeftWidth={40}
|
</div>
|
||||||
height={config.height}
|
<div className="border-r py-3.5 text-center last:border-r-0">
|
||||||
/>
|
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
|
||||||
|
{stats.totalValues}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
전체 값
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-r py-3.5 text-center last:border-r-0">
|
||||||
|
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
|
||||||
|
{stats.tableCount}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
테이블
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-3.5 text-center">
|
||||||
|
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
|
||||||
|
{stats.inactiveCount}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
비활성
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* 좌측 테이블 nav: 240px */}
|
||||||
|
<div className="flex w-[240px] shrink-0 flex-col border-r">
|
||||||
|
<CategoryColumnList
|
||||||
|
tableName={effectiveTableName}
|
||||||
|
selectedColumn={selectedColumn?.uniqueKey ?? null}
|
||||||
|
onColumnSelect={handleColumnSelect}
|
||||||
|
menuObjid={effectiveMenuObjid}
|
||||||
|
selectedTable={selectedTable}
|
||||||
|
onTableSelect={setSelectedTable}
|
||||||
|
onColumnsLoaded={handleColumnsLoaded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 칩 바 + 편집기 */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* 칩 바 */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 border-b bg-background px-4 py-3">
|
||||||
|
{columnsForSelectedTable.map((col) => {
|
||||||
|
const uniqueKey = `${col.tableName}.${col.columnName}`;
|
||||||
|
const isActive = selectedColumn?.uniqueKey === uniqueKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={uniqueKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleColumnSelect(uniqueKey, col.columnLabel || col.columnName, col.tableName)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-[11px] font-semibold transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-border bg-muted/50 hover:border-primary hover:bg-primary/5 hover:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{col.columnLabel || col.columnName}</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
"h-4 rounded-full px-1.5 text-[9px] font-bold",
|
||||||
|
isActive ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{col.valueCount ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedTable && columnsForSelectedTable.length === 0 && (
|
||||||
|
<span className="text-muted-foreground text-xs">이 테이블에 카테고리 컬럼이 없습니다</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 편집기 영역 */}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
|
||||||
|
{rightContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default V2CategoryManagerComponent;
|
export default V2CategoryManagerComponent;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
GripVertical,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
|
@ -21,6 +20,8 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
Move,
|
Move,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
List,
|
||||||
|
LayoutPanelRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
|
|
@ -325,6 +326,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
||||||
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
||||||
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
// 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 순서 변경)
|
||||||
|
const [rightDraggedColumnIndex, setRightDraggedColumnIndex] = useState<number | null>(null);
|
||||||
|
const [rightDropTargetColumnIndex, setRightDropTargetColumnIndex] = useState<number | null>(null);
|
||||||
|
const [rightDragSource, setRightDragSource] = useState<"main" | number | null>(null);
|
||||||
|
|
||||||
// 데이터 상태
|
// 데이터 상태
|
||||||
const [leftData, setLeftData] = useState<any[]>([]);
|
const [leftData, setLeftData] = useState<any[]>([]);
|
||||||
|
|
@ -2631,6 +2636,95 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
}, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]);
|
}, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]);
|
||||||
|
|
||||||
|
// 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 컬럼 순서 변경)
|
||||||
|
const handleRightColumnDragStart = useCallback(
|
||||||
|
(columnIndex: number, source: "main" | number) => {
|
||||||
|
setRightDraggedColumnIndex(columnIndex);
|
||||||
|
setRightDragSource(source);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleRightColumnDragOver = useCallback((e: React.DragEvent, columnIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setRightDropTargetColumnIndex(columnIndex);
|
||||||
|
}, []);
|
||||||
|
const handleRightColumnDragEnd = useCallback(() => {
|
||||||
|
setRightDraggedColumnIndex(null);
|
||||||
|
setRightDropTargetColumnIndex(null);
|
||||||
|
setRightDragSource(null);
|
||||||
|
}, []);
|
||||||
|
const handleRightColumnDrop = useCallback(
|
||||||
|
(e: React.DragEvent, targetIndex: number, source: "main" | number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fromIdx = rightDraggedColumnIndex;
|
||||||
|
if (fromIdx === null || rightDragSource !== source || fromIdx === targetIndex) {
|
||||||
|
handleRightColumnDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!onUpdateComponent) {
|
||||||
|
handleRightColumnDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rightPanel = componentConfig.rightPanel || {};
|
||||||
|
if (source === "main") {
|
||||||
|
const allColumns = rightPanel.columns || [];
|
||||||
|
const visibleColumns = allColumns.filter((c: any) => c.showInSummary !== false);
|
||||||
|
const hiddenColumns = allColumns.filter((c: any) => c.showInSummary === false);
|
||||||
|
if (fromIdx < 0 || fromIdx >= visibleColumns.length || targetIndex < 0 || targetIndex >= visibleColumns.length) {
|
||||||
|
handleRightColumnDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reordered = [...visibleColumns];
|
||||||
|
const [removed] = reordered.splice(fromIdx, 1);
|
||||||
|
reordered.splice(targetIndex, 0, removed);
|
||||||
|
const columns = [...reordered, ...hiddenColumns];
|
||||||
|
onUpdateComponent({
|
||||||
|
...component,
|
||||||
|
componentConfig: {
|
||||||
|
...componentConfig,
|
||||||
|
rightPanel: { ...rightPanel, columns },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const tabs = [...(rightPanel.additionalTabs || [])];
|
||||||
|
const tabConfig = tabs[source];
|
||||||
|
if (!tabConfig || !Array.isArray(tabConfig.columns)) {
|
||||||
|
handleRightColumnDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allTabCols = tabConfig.columns;
|
||||||
|
const visibleTabCols = allTabCols.filter((c: any) => c.showInSummary !== false);
|
||||||
|
const hiddenTabCols = allTabCols.filter((c: any) => c.showInSummary === false);
|
||||||
|
if (fromIdx < 0 || fromIdx >= visibleTabCols.length || targetIndex < 0 || targetIndex >= visibleTabCols.length) {
|
||||||
|
handleRightColumnDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reordered = [...visibleTabCols];
|
||||||
|
const [removed] = reordered.splice(fromIdx, 1);
|
||||||
|
reordered.splice(targetIndex, 0, removed);
|
||||||
|
const columns = [...reordered, ...hiddenTabCols];
|
||||||
|
const newTabs = tabs.map((t, i) => (i === source ? { ...t, columns } : t));
|
||||||
|
onUpdateComponent({
|
||||||
|
...component,
|
||||||
|
componentConfig: {
|
||||||
|
...componentConfig,
|
||||||
|
rightPanel: { ...rightPanel, additionalTabs: newTabs },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
handleRightColumnDragEnd();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
rightDraggedColumnIndex,
|
||||||
|
rightDragSource,
|
||||||
|
componentConfig,
|
||||||
|
component,
|
||||||
|
onUpdateComponent,
|
||||||
|
handleRightColumnDragEnd,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// 수정 모달 저장
|
// 수정 모달 저장
|
||||||
const handleEditModalSave = useCallback(async () => {
|
const handleEditModalSave = useCallback(async () => {
|
||||||
const tableName =
|
const tableName =
|
||||||
|
|
@ -3212,10 +3306,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
<CardTitle className="text-base font-semibold">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
{componentConfig.leftPanel?.title || "좌측 패널"}
|
<List className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
</CardTitle>
|
<CardTitle className="truncate text-base font-semibold">
|
||||||
|
{componentConfig.leftPanel?.title || "좌측 패널"}
|
||||||
|
</CardTitle>
|
||||||
|
{!isDesignMode && (
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||||
|
{summedLeftData.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
|
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setBomExcelUploadOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setBomExcelUploadOpen(true)}>
|
||||||
|
|
@ -4011,13 +4113,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 리사이저 */}
|
{/* 리사이저: 6px 너비, 그립 핸들(2x28px bar), hover 시 primary 하이라이트 */}
|
||||||
{resizable && (
|
{resizable && (
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
className="group bg-border hover:bg-primary flex w-1 cursor-col-resize items-center justify-center transition-colors"
|
className="group flex w-1.5 cursor-col-resize flex-col items-center justify-center gap-0.5 bg-border transition-colors hover:bg-primary"
|
||||||
|
aria-label="분할선 드래그"
|
||||||
>
|
>
|
||||||
<GripVertical className="text-muted-foreground group-hover:text-primary-foreground h-4 w-4" />
|
<div className="h-7 w-0.5 rounded-full bg-muted-foreground/40 transition-colors group-hover:bg-primary-foreground/80" />
|
||||||
|
<div className="h-7 w-0.5 rounded-full bg-muted-foreground/40 transition-colors group-hover:bg-primary-foreground/80" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -4037,9 +4141,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 */}
|
<LayoutPanelRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */}
|
||||||
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
|
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex items-center gap-0">
|
||||||
<button
|
<button
|
||||||
|
|
@ -4069,10 +4174,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CardTitle className="text-base font-semibold">
|
<CardTitle className="truncate text-base font-semibold">
|
||||||
{componentConfig.rightPanel?.title || "우측 패널"}
|
{componentConfig.rightPanel?.title || "우측 패널"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
)}
|
)}
|
||||||
|
{!isDesignMode && (
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||||
|
{activeTabIndex === 0
|
||||||
|
? Array.isArray(rightData)
|
||||||
|
? rightData.length
|
||||||
|
: rightData ? 1 : 0
|
||||||
|
: (tabsData[activeTabIndex]?.length ?? 0)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -4163,16 +4277,35 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
||||||
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||||
const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
||||||
|
const tabIndex = activeTabIndex - 1;
|
||||||
|
const canDragTabColumns = isDesignMode && tabSummaryColumns.length > 0 && !!onUpdateComponent;
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto">
|
<div className="h-full overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 z-10 bg-background">
|
<thead className="sticky top-0 z-10 bg-background">
|
||||||
<tr className="border-b-2 border-border/60">
|
<tr className="border-b-2 border-border/60">
|
||||||
{tabSummaryColumns.map((col: any) => (
|
{tabSummaryColumns.map((col: any, idx: number) => {
|
||||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
const isDropTarget = rightDragSource === tabIndex && rightDropTargetColumnIndex === idx;
|
||||||
{col.label || col.name}
|
const isDragging = rightDragSource === tabIndex && rightDraggedColumnIndex === idx;
|
||||||
</th>
|
return (
|
||||||
))}
|
<th
|
||||||
|
key={col.name}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold",
|
||||||
|
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
|
||||||
|
canDragTabColumns && "cursor-grab active:cursor-grabbing",
|
||||||
|
isDragging && "opacity-50",
|
||||||
|
)}
|
||||||
|
draggable={canDragTabColumns}
|
||||||
|
onDragStart={() => canDragTabColumns && handleRightColumnDragStart(idx, tabIndex)}
|
||||||
|
onDragOver={(e) => canDragTabColumns && handleRightColumnDragOver(e, idx)}
|
||||||
|
onDragEnd={handleRightColumnDragEnd}
|
||||||
|
onDrop={(e) => canDragTabColumns && handleRightColumnDrop(e, idx, tabIndex)}
|
||||||
|
>
|
||||||
|
{col.label || col.name}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{hasTabActions && (
|
{hasTabActions && (
|
||||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||||
)}
|
)}
|
||||||
|
|
@ -4280,16 +4413,35 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
||||||
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||||
const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
||||||
|
const listTabIndex = activeTabIndex - 1;
|
||||||
|
const canDragListTabColumns = isDesignMode && listSummaryColumns.length > 0 && !!onUpdateComponent;
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto">
|
<div className="h-full overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 z-10 bg-background">
|
<thead className="sticky top-0 z-10 bg-background">
|
||||||
<tr className="border-b-2 border-border/60">
|
<tr className="border-b-2 border-border/60">
|
||||||
{listSummaryColumns.map((col: any) => (
|
{listSummaryColumns.map((col: any, idx: number) => {
|
||||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
const isDropTarget = rightDragSource === listTabIndex && rightDropTargetColumnIndex === idx;
|
||||||
{col.label || col.name}
|
const isDragging = rightDragSource === listTabIndex && rightDraggedColumnIndex === idx;
|
||||||
</th>
|
return (
|
||||||
))}
|
<th
|
||||||
|
key={col.name}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold",
|
||||||
|
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
|
||||||
|
canDragListTabColumns && "cursor-grab active:cursor-grabbing",
|
||||||
|
isDragging && "opacity-50",
|
||||||
|
)}
|
||||||
|
draggable={canDragListTabColumns}
|
||||||
|
onDragStart={() => canDragListTabColumns && handleRightColumnDragStart(idx, listTabIndex)}
|
||||||
|
onDragOver={(e) => canDragListTabColumns && handleRightColumnDragOver(e, idx)}
|
||||||
|
onDragEnd={handleRightColumnDragEnd}
|
||||||
|
onDrop={(e) => canDragListTabColumns && handleRightColumnDrop(e, idx, listTabIndex)}
|
||||||
|
>
|
||||||
|
{col.label || col.name}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{hasTabActions && (
|
{hasTabActions && (
|
||||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||||
)}
|
)}
|
||||||
|
|
@ -4672,24 +4824,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return sum + w;
|
return sum + w;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length;
|
||||||
|
const canDragRightColumns = isDesignMode && displayColumns.length > 0 && !!onUpdateComponent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-full w-full flex-col">
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
<table className="table-fixed" style={{ width: rightTotalColWidth > 100 ? `${rightTotalColWidth}%` : '100%' }}>
|
<table className="table-fixed" style={{ width: rightTotalColWidth > 100 ? `${rightTotalColWidth}%` : '100%' }}>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="border-b-2 border-border/60">
|
<tr className="border-b-2 border-border/60">
|
||||||
{columnsToShow.map((col, idx) => (
|
{columnsToShow.map((col, idx) => {
|
||||||
<th
|
const configColIndex = idx - rightConfigColumnStart;
|
||||||
key={idx}
|
const isDraggable = canDragRightColumns && !col._isKeyColumn;
|
||||||
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
|
const isDropTarget = rightDragSource === "main" && rightDropTargetColumnIndex === configColIndex;
|
||||||
style={{
|
const isDragging = rightDragSource === "main" && rightDraggedColumnIndex === configColIndex;
|
||||||
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
|
return (
|
||||||
textAlign: col.align || "left",
|
<th
|
||||||
}}
|
key={idx}
|
||||||
>
|
className={cn(
|
||||||
{col.label}
|
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap",
|
||||||
</th>
|
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
|
||||||
))}
|
isDraggable && "cursor-grab active:cursor-grabbing",
|
||||||
|
isDragging && "opacity-50",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
|
||||||
|
textAlign: col.align || "left",
|
||||||
|
}}
|
||||||
|
draggable={isDraggable}
|
||||||
|
onDragStart={() => isDraggable && handleRightColumnDragStart(configColIndex, "main")}
|
||||||
|
onDragOver={(e) => isDraggable && handleRightColumnDragOver(e, configColIndex)}
|
||||||
|
onDragEnd={handleRightColumnDragEnd}
|
||||||
|
onDrop={(e) => isDraggable && handleRightColumnDrop(e, configColIndex, "main")}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */}
|
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */}
|
||||||
{!isDesignMode &&
|
{!isDesignMode &&
|
||||||
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||||
|
|
@ -4705,7 +4876,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const itemId = item.id || item.ID || idx;
|
const itemId = item.id || item.ID || idx;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={itemId} className={cn("border-b border-border/40 transition-colors hover:bg-muted/30", idx % 2 === 1 && "bg-muted/10")}>
|
<tr key={itemId} className={cn("group/action border-b border-border/40 transition-colors hover:bg-muted/30", idx % 2 === 1 && "bg-muted/10")}>
|
||||||
{columnsToShow.map((col, colIdx) => (
|
{columnsToShow.map((col, colIdx) => (
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
|
|
@ -4726,8 +4897,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{!isDesignMode &&
|
{!isDesignMode &&
|
||||||
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
||||||
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
<td className="px-3 py-2 text-right text-sm whitespace-nowrap group/action">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1 opacity-0 transition-opacity group-hover/action:opacity-100">
|
||||||
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={
|
||||||
|
|
@ -4850,7 +5021,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<React.Fragment key={itemId}>
|
<React.Fragment key={itemId}>
|
||||||
<tr
|
<tr
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer border-b border-border/40 transition-colors",
|
"group/action cursor-pointer border-b border-border/40 transition-colors",
|
||||||
isExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
isExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleRightItemExpansion(itemId)}
|
onClick={() => toggleRightItemExpansion(itemId)}
|
||||||
|
|
@ -4867,7 +5038,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
))}
|
))}
|
||||||
{hasActions && (
|
{hasActions && (
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover/action:opacity-100">
|
||||||
{hasEditButton && (
|
{hasEditButton && (
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -4984,8 +5155,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasDetailEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
|
||||||
|
const hasDetailDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{(hasDetailEditButton || hasDetailDeleteButton) && (
|
||||||
|
<div className="flex items-center justify-end gap-1 pb-1">
|
||||||
|
{hasDetailEditButton && (
|
||||||
|
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs"
|
||||||
|
onClick={() => handleEditClick("right", rightData)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasDetailDeleteButton && (
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive h-7 gap-1 px-2 text-xs"
|
||||||
|
onClick={() => handleDeleteClick("right", rightData)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
{componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{displayEntries.map(([key, value, label]) => (
|
{displayEntries.map(([key, value, label]) => (
|
||||||
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
|
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||||
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
|
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,11 @@ interface SingleTableWithStickyProps {
|
||||||
editingValue?: string;
|
editingValue?: string;
|
||||||
onEditingValueChange?: (value: string) => void;
|
onEditingValueChange?: (value: string) => void;
|
||||||
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
editInputRef?: React.RefObject<HTMLInputElement>;
|
onEditSave?: () => void;
|
||||||
|
editInputRef?: React.RefObject<HTMLInputElement | HTMLSelectElement>;
|
||||||
|
// 인라인 편집 타입별 옵션 (select/category/code, number, date 지원)
|
||||||
|
columnMeta?: Record<string, { inputType?: string }>;
|
||||||
|
categoryMappings?: Record<string, Record<string, { label: string }>>;
|
||||||
// 검색 하이라이트 관련 props
|
// 검색 하이라이트 관련 props
|
||||||
searchHighlights?: Set<string>;
|
searchHighlights?: Set<string>;
|
||||||
currentSearchIndex?: number;
|
currentSearchIndex?: number;
|
||||||
|
|
@ -69,7 +73,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
editingValue,
|
editingValue,
|
||||||
onEditingValueChange,
|
onEditingValueChange,
|
||||||
onEditKeyDown,
|
onEditKeyDown,
|
||||||
|
onEditSave,
|
||||||
editInputRef,
|
editInputRef,
|
||||||
|
columnMeta,
|
||||||
|
categoryMappings,
|
||||||
// 검색 하이라이트 관련 props
|
// 검색 하이라이트 관련 props
|
||||||
searchHighlights,
|
searchHighlights,
|
||||||
currentSearchIndex = 0,
|
currentSearchIndex = 0,
|
||||||
|
|
@ -350,15 +357,19 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
{column.columnName === "__checkbox__" ? (
|
{column.columnName === "__checkbox__" ? (
|
||||||
renderCheckboxCell?.(row, index)
|
renderCheckboxCell?.(row, index)
|
||||||
) : isEditing ? (
|
) : isEditing ? (
|
||||||
// 인라인 편집 입력 필드
|
// 인라인 편집: inputType에 따라 select(category/code), number, date, text
|
||||||
<input
|
(() => {
|
||||||
ref={editInputRef}
|
const meta = columnMeta?.[column.columnName];
|
||||||
type="text"
|
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
|
||||||
value={editingValue ?? ""}
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||||
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
const isCategoryType = inputType === "category" || inputType === "code";
|
||||||
onKeyDown={onEditKeyDown}
|
const categoryOptions = categoryMappings?.[column.columnName];
|
||||||
onBlur={() => {
|
const hasCategoryOptions =
|
||||||
// blur 시 저장 (Enter와 동일)
|
isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0;
|
||||||
|
|
||||||
|
const commonInputClass =
|
||||||
|
"border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm";
|
||||||
|
const handleBlurSave = () => {
|
||||||
if (onEditKeyDown) {
|
if (onEditKeyDown) {
|
||||||
const fakeEvent = {
|
const fakeEvent = {
|
||||||
key: "Enter",
|
key: "Enter",
|
||||||
|
|
@ -366,10 +377,78 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
} as React.KeyboardEvent<HTMLInputElement>;
|
} as React.KeyboardEvent<HTMLInputElement>;
|
||||||
onEditKeyDown(fakeEvent);
|
onEditKeyDown(fakeEvent);
|
||||||
}
|
}
|
||||||
}}
|
onEditSave?.();
|
||||||
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
|
};
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
if (hasCategoryOptions) {
|
||||||
|
const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({
|
||||||
|
value,
|
||||||
|
label: info.label,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
ref={editInputRef as React.RefObject<HTMLSelectElement>}
|
||||||
|
value={editingValue ?? ""}
|
||||||
|
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||||
|
onKeyDown={onEditKeyDown}
|
||||||
|
onBlur={handleBlurSave}
|
||||||
|
className={commonInputClass}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
{selectOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputType === "date" || inputType === "datetime") {
|
||||||
|
try {
|
||||||
|
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
|
||||||
|
return (
|
||||||
|
<InlineCellDatePicker
|
||||||
|
value={editingValue ?? ""}
|
||||||
|
onChange={(v) => onEditingValueChange?.(v)}
|
||||||
|
onSave={() => {
|
||||||
|
handleBlurSave();
|
||||||
|
}}
|
||||||
|
onKeyDown={onEditKeyDown}
|
||||||
|
inputRef={editInputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={editInputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
type="text"
|
||||||
|
value={editingValue ?? ""}
|
||||||
|
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||||
|
onKeyDown={onEditKeyDown}
|
||||||
|
onBlur={handleBlurSave}
|
||||||
|
className={commonInputClass}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={editInputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
type={isNumeric ? "number" : "text"}
|
||||||
|
value={editingValue ?? ""}
|
||||||
|
onChange={(e) => onEditingValueChange?.(e.target.value)}
|
||||||
|
onKeyDown={onEditKeyDown}
|
||||||
|
onBlur={handleBlurSave}
|
||||||
|
className={commonInputClass}
|
||||||
|
style={isNumeric ? { textAlign: "right" } : undefined}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
) : (
|
) : (
|
||||||
renderCellContent()
|
renderCellContent()
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5463,6 +5463,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
getColumnWidth={getColumnWidth}
|
getColumnWidth={getColumnWidth}
|
||||||
containerWidth={calculatedWidth}
|
containerWidth={calculatedWidth}
|
||||||
|
onCellDoubleClick={handleCellDoubleClick}
|
||||||
|
editingCell={editingCell}
|
||||||
|
editingValue={editingValue}
|
||||||
|
onEditingValueChange={setEditingValue}
|
||||||
|
onEditKeyDown={handleEditKeyDown}
|
||||||
|
onEditSave={saveEditing}
|
||||||
|
editInputRef={editInputRef}
|
||||||
|
columnMeta={columnMeta}
|
||||||
|
categoryMappings={categoryMappings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -6410,7 +6419,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onChange={(e) => setEditingValue(e.target.value)}
|
onChange={(e) => setEditingValue(e.target.value)}
|
||||||
onKeyDown={handleEditKeyDown}
|
onKeyDown={handleEditKeyDown}
|
||||||
onBlur={saveEditing}
|
onBlur={saveEditing}
|
||||||
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
className="border-primary bg-background h-8 w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
>
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
|
|
@ -6447,7 +6456,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onChange={(e) => setEditingValue(e.target.value)}
|
onChange={(e) => setEditingValue(e.target.value)}
|
||||||
onKeyDown={handleEditKeyDown}
|
onKeyDown={handleEditKeyDown}
|
||||||
onBlur={saveEditing}
|
onBlur={saveEditing}
|
||||||
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
className="border-primary bg-background h-8 w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
||||||
style={{
|
style={{
|
||||||
textAlign: isNumeric ? "right" : column.align || "left",
|
textAlign: isNumeric ? "right" : column.align || "left",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue