feat(universal-form-modal): 옵셔널 필드 그룹 및 카테고리 Select 옵션 기능 추가
- 옵셔널 필드 그룹: 섹션 내 선택적 필드 그룹 지원 (추가/제거, 연동 필드 자동 변경) - 카테고리 Select: table_column_category_values 테이블 값을 Select 옵션으로 사용 - 전체 카테고리 컬럼 조회 API: GET /api/table-categories/all-columns - RepeaterFieldGroup 저장 시 공통 필드 자동 병합
This commit is contained in:
@@ -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] || [];
|
||||
|
||||
Reference in New Issue
Block a user