feat(universal-form-modal): 옵셔널 필드 그룹 및 카테고리 Select 옵션 기능 추가

- 옵셔널 필드 그룹: 섹션 내 선택적 필드 그룹 지원 (추가/제거, 연동 필드 자동 변경)
- 카테고리 Select: table_column_category_values 테이블 값을 Select 옵션으로 사용
- 전체 카테고리 컬럼 조회 API: GET /api/table-categories/all-columns
- RepeaterFieldGroup 저장 시 공통 필드 자동 병합
This commit is contained in:
SeongHyun Kim
2025-12-17 14:30:29 +09:00
parent 31746e8a0b
commit ccbbf46faf
15 changed files with 964 additions and 53 deletions

View File

@@ -19,7 +19,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -35,6 +35,7 @@ import {
FormDataState,
RepeatSectionItem,
SelectOptionConfig,
OptionalFieldGroupConfig,
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
@@ -177,6 +178,9 @@ export function UniversalFormModalComponent({
// 섹션 접힘 상태
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
// 옵셔널 필드 그룹 활성화 상태 (섹션ID-그룹ID 조합)
const [activatedOptionalFieldGroups, setActivatedOptionalFieldGroups] = useState<Set<string>>(new Set());
// Select 옵션 캐시
const [selectOptionsCache, setSelectOptionsCache] = useState<{
[key: string]: { value: string; label: string }[];
@@ -575,6 +579,49 @@ export function UniversalFormModalComponent({
});
}, []);
// 옵셔널 필드 그룹 활성화
const activateOptionalFieldGroup = useCallback((sectionId: string, groupId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
if (!group) return;
const key = `${sectionId}-${groupId}`;
setActivatedOptionalFieldGroups((prev) => {
const newSet = new Set(prev);
newSet.add(key);
return newSet;
});
// 연동 필드 값 변경 (추가 시)
if (group.triggerField && group.triggerValueOnAdd !== undefined) {
handleFieldChange(group.triggerField, group.triggerValueOnAdd);
}
}, [config, handleFieldChange]);
// 옵셔널 필드 그룹 비활성화
const deactivateOptionalFieldGroup = useCallback((sectionId: string, groupId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
if (!group) return;
const key = `${sectionId}-${groupId}`;
setActivatedOptionalFieldGroups((prev) => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
// 연동 필드 값 변경 (제거 시)
if (group.triggerField && group.triggerValueOnRemove !== undefined) {
handleFieldChange(group.triggerField, group.triggerValueOnRemove);
}
// 옵셔널 필드 그룹 필드 값 초기화
group.fields.forEach((field) => {
handleFieldChange(field.columnName, field.defaultValue || "");
});
}, [config, handleFieldChange]);
// Select 옵션 로드
const loadSelectOptions = useCallback(
async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => {
@@ -587,9 +634,10 @@ export function UniversalFormModalComponent({
try {
if (optionConfig.type === "static") {
// 직접 입력: 설정된 정적 옵션 사용
options = optionConfig.staticOptions || [];
} else if (optionConfig.type === "table" && optionConfig.tableName) {
// POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
// 테이블 참조: POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, {
page: 1,
size: 1000,
@@ -613,13 +661,21 @@ export function UniversalFormModalComponent({
value: String(row[optionConfig.valueColumn || "id"]),
label: String(row[optionConfig.labelColumn || "name"]),
}));
} else if (optionConfig.type === "code" && optionConfig.codeCategory) {
const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
if (response.data?.success && response.data?.data) {
options = response.data.data.map((code: any) => ({
value: code.code_value || code.codeValue,
label: code.code_name || code.codeName,
}));
} else if (optionConfig.type === "code" && optionConfig.categoryKey) {
// 공통코드(카테고리 컬럼): table_column_category_values 테이블에서 조회
// categoryKey 형식: "tableName.columnName"
const [categoryTable, categoryColumn] = optionConfig.categoryKey.split(".");
if (categoryTable && categoryColumn) {
const response = await apiClient.get(
`/table-categories/${categoryTable}/${categoryColumn}/values`
);
if (response.data?.success && response.data?.data) {
// 라벨값을 DB에 저장 (화면에 표시되는 값 그대로 저장)
options = response.data.data.map((item: any) => ({
value: item.valueLabel || item.value_label,
label: item.valueLabel || item.value_label,
}));
}
}
}
@@ -1500,6 +1556,15 @@ export function UniversalFormModalComponent({
),
)}
</div>
{/* 옵셔널 필드 그룹 렌더링 */}
{section.optionalFieldGroups && section.optionalFieldGroups.length > 0 && (
<div className="mt-4 space-y-3">
{section.optionalFieldGroups.map((group) =>
renderOptionalFieldGroup(section, group, sectionColumns)
)}
</div>
)}
</CardContent>
</>
)}
@@ -1507,6 +1572,175 @@ export function UniversalFormModalComponent({
);
};
// 옵셔널 필드 그룹 접힘 상태 관리
const [collapsedOptionalGroups, setCollapsedOptionalGroups] = useState<Set<string>>(() => {
// 초기 접힘 상태 설정
const initialCollapsed = new Set<string>();
config.sections.forEach((section) => {
section.optionalFieldGroups?.forEach((group) => {
if (group.defaultCollapsed) {
initialCollapsed.add(`${section.id}-${group.id}`);
}
});
});
return initialCollapsed;
});
// 옵셔널 필드 그룹 렌더링
const renderOptionalFieldGroup = (
section: FormSectionConfig,
group: OptionalFieldGroupConfig,
sectionColumns: number
) => {
const key = `${section.id}-${group.id}`;
const isActivated = activatedOptionalFieldGroups.has(key);
const isCollapsed = collapsedOptionalGroups.has(key);
const groupColumns = group.columns || sectionColumns;
const addButtonText = group.addButtonText || `+ ${group.title} 추가`;
const removeButtonText = group.removeButtonText || "제거";
// 비활성화 상태: 추가 버튼만 표시
if (!isActivated) {
return (
<div
key={group.id}
className="hover:border-primary/50 hover:bg-muted/30 border-muted rounded-lg border-2 border-dashed p-3 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">{group.title}</p>
{group.description && (
<p className="text-muted-foreground/70 mt-0.5 text-xs">{group.description}</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => activateOptionalFieldGroup(section.id, group.id)}
className="h-8 shrink-0 text-xs"
>
<Plus className="mr-1 h-3.5 w-3.5" />
{addButtonText}
</Button>
</div>
</div>
);
}
// 활성화 상태: 필드 그룹 표시
// collapsible 설정에 따라 접기/펼치기 지원
if (group.collapsible) {
return (
<Collapsible
key={group.id}
open={!isCollapsed}
onOpenChange={(open) => {
setCollapsedOptionalGroups((prev) => {
const newSet = new Set(prev);
if (open) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
}}
className="border-primary/30 bg-muted/10 rounded-lg border"
>
<div className="flex items-center justify-between p-3">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-left hover:opacity-80">
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
<div>
<p className="text-sm font-medium">{group.title}</p>
{group.description && (
<p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>
)}
</div>
</button>
</CollapsibleTrigger>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (group.confirmRemove) {
if (confirm(`${group.title}을(를) 제거하시겠습니까?\n입력한 내용이 초기화됩니다.`)) {
deactivateOptionalFieldGroup(section.id, group.id);
}
} else {
deactivateOptionalFieldGroup(section.id, group.id);
}
}}
className="text-muted-foreground hover:text-destructive h-7 shrink-0 text-xs"
>
<Trash2 className="mr-1 h-3 w-3" />
{removeButtonText}
</Button>
</div>
<CollapsibleContent>
<div className="grid gap-3 px-3 pb-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
{group.fields.map((field) =>
renderFieldWithColumns(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${group.id}-${field.id}`,
groupColumns
)
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
// 접기 비활성화: 일반 표시
return (
<div key={group.id} className="border-primary/30 bg-muted/10 rounded-lg border p-3">
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium">{group.title}</p>
{group.description && (
<p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (group.confirmRemove) {
if (confirm(`${group.title}을(를) 제거하시겠습니까?\n입력한 내용이 초기화됩니다.`)) {
deactivateOptionalFieldGroup(section.id, group.id);
}
} else {
deactivateOptionalFieldGroup(section.id, group.id);
}
}}
className="text-muted-foreground hover:text-destructive h-7 shrink-0 text-xs"
>
<Trash2 className="mr-1 h-3 w-3" />
{removeButtonText}
</Button>
</div>
<div className="grid gap-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
{group.fields.map((field) =>
renderFieldWithColumns(
field,
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${group.id}-${field.id}`,
groupColumns
)
)}
</div>
</div>
);
};
// 반복 섹션 렌더링
const renderRepeatableSection = (section: FormSectionConfig, isCollapsed: boolean) => {
const items = repeatSections[section.id] || [];