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

@ -30,6 +30,29 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons
}
};
/**
* (Select )
*/
export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const columns = await tableCategoryValueService.getAllCategoryColumns(companyCode);
return res.json({
success: true,
data: columns,
});
} catch (error: any) {
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "전체 카테고리 컬럼 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* ( )
*

View File

@ -877,7 +877,17 @@ export async function addTableData(
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
}
}
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
const userId = req.user?.userId;
if (userId && !data.writer) {
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
if (hasWriterColumn) {
data.writer = userId;
logger.info(`writer 자동 추가 - ${userId}`);
}
}

View File

@ -1,6 +1,7 @@
import { Router } from "express";
import {
getCategoryColumns,
getAllCategoryColumns,
getCategoryValues,
addCategoryValue,
updateCategoryValue,
@ -22,6 +23,10 @@ const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
// 주의: 더 구체적인 라우트보다 먼저 와야 함
router.get("/all-columns", getAllCategoryColumns);
// 테이블의 카테고리 컬럼 목록 조회
router.get("/:tableName/columns", getCategoryColumns);

View File

@ -86,11 +86,12 @@ export class CommonCodeService {
}
// 회사별 필터링 (최고 관리자가 아닌 경우)
// company_code = '*'인 공통 데이터도 함께 조회
if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
values.push(userCompanyCode);
paramIndex++;
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`);
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
} else if (userCompanyCode === "*") {
// 최고 관리자는 모든 데이터 조회 가능
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
@ -116,7 +117,7 @@ export class CommonCodeService {
const offset = (page - 1) * size;
// 카테고리 조회
// code_category 테이블에서만 조회 (comm_code 제거)
const categories = await query<CodeCategory>(
`SELECT * FROM code_category
${whereClause}
@ -134,7 +135,7 @@ export class CommonCodeService {
const total = parseInt(countResult?.count || "0");
logger.info(
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
`카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
);
return {
@ -224,7 +225,7 @@ export class CommonCodeService {
paramIndex,
});
// 코드 조회
// code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
const codes = await query<CodeInfo>(
`SELECT * FROM code_info
${whereClause}
@ -242,20 +243,9 @@ export class CommonCodeService {
const total = parseInt(countResult?.count || "0");
logger.info(
`✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
);
logger.info(`📊 [getCodes] 조회된 코드 상세:`, {
categoryCode,
menuObjid,
codes: codes.map((c) => ({
code_value: c.code_value,
code_name: c.code_name,
menu_objid: c.menu_objid,
company_code: c.company_code,
})),
});
return { data: codes, total };
} catch (error) {
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);

View File

@ -79,6 +79,82 @@ class TableCategoryValueService {
}
}
/**
* (Select )
* .
*/
async getAllCategoryColumns(
companyCode: string
): Promise<CategoryColumn[]> {
try {
logger.info("전체 카테고리 컬럼 목록 조회", { companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거)
query = `
SELECT
tc.table_name AS "tableName",
tc.column_name AS "columnName",
tc.column_name AS "columnLabel",
COALESCE(cv_count.cnt, 0) AS "valueCount"
FROM (
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
FROM table_type_columns
WHERE input_type = 'category'
GROUP BY table_name, column_name
) tc
LEFT JOIN (
SELECT table_name, column_name, COUNT(*) as cnt
FROM table_column_category_values
WHERE is_active = true
GROUP BY table_name, column_name
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
ORDER BY tc.table_name, tc.display_order, tc.column_name
`;
params = [];
} else {
// 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거)
query = `
SELECT
tc.table_name AS "tableName",
tc.column_name AS "columnName",
tc.column_name AS "columnLabel",
COALESCE(cv_count.cnt, 0) AS "valueCount"
FROM (
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
FROM table_type_columns
WHERE input_type = 'category'
GROUP BY table_name, column_name
) tc
LEFT JOIN (
SELECT table_name, column_name, COUNT(*) as cnt
FROM table_column_category_values
WHERE is_active = true AND company_code = $1
GROUP BY table_name, column_name
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
ORDER BY tc.table_name, tc.display_order, tc.column_name
`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
companyCode,
});
return result.rows;
} catch (error: any) {
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
throw error;
}
}
/**
* ( )
*

View File

@ -101,3 +101,4 @@
- [split-panel-layout (v1)](../split-panel-layout/README.md)

View File

@ -41,3 +41,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer
SplitPanelLayout2Renderer.registerSelf();

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] || [];

View File

@ -499,7 +499,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
onOpenChange={setSectionLayoutModalOpen}
section={selectedSection}
onSave={(updates) => {
// config 업데이트
updateSection(selectedSection.id, updates);
// selectedSection 상태도 업데이트 (최신 상태 유지)
setSelectedSection({ ...selectedSection, ...updates });
setSectionLayoutModalOpen(false);
}}
onOpenFieldDetail={(field) => {
@ -522,18 +525,30 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
}
}}
field={selectedField}
onSave={(updates) => {
onSave={(updatedField) => {
// updatedField는 FieldDetailSettingsModal에서 전달된 전체 필드 객체
const updatedSection = {
...selectedSection,
// 기본 필드 목록에서 업데이트
fields: selectedSection.fields.map((f) => (f.id === updatedField.id ? updatedField : f)),
// 옵셔널 필드 그룹 내 필드도 업데이트
optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({
...group,
fields: group.fields.map((f) => (f.id === updatedField.id ? updatedField : f)),
})),
};
// config 업데이트
onChange({
...config,
sections: config.sections.map((s) =>
s.id === selectedSection.id
? {
...s,
fields: s.fields.map((f) => (f.id === selectedField.id ? { ...f, ...updates } : f)),
}
: s,
s.id === selectedSection.id ? updatedSection : s
),
});
// selectedSection과 selectedField 상태도 업데이트 (다음에 다시 열었을 때 최신 값 반영)
setSelectedSection(updatedSection);
setSelectedField(updatedField as FormFieldConfig);
setFieldDetailModalOpen(false);
setSectionLayoutModalOpen(true);
}}

View File

@ -91,9 +91,30 @@ export const defaultSectionConfig = {
itemTitle: "항목 {index}",
confirmRemove: false,
},
optionalFieldGroups: [],
linkedFieldGroups: [],
};
// 기본 옵셔널 필드 그룹 설정
export const defaultOptionalFieldGroupConfig = {
id: "",
fields: [],
// 섹션 스타일 설정
title: "옵셔널 그룹",
description: "",
columns: undefined, // undefined면 부모 섹션 columns 상속
collapsible: false,
defaultCollapsed: false,
// 버튼 설정
addButtonText: "",
removeButtonText: "제거",
confirmRemove: false,
// 연동 필드 설정
triggerField: "",
triggerValueOnAdd: "",
triggerValueOnRemove: "",
};
// 기본 연동 필드 그룹 설정
export const defaultLinkedFieldGroupConfig = {
id: "",

View File

@ -19,6 +19,17 @@ import {
SELECT_OPTION_TYPE_OPTIONS,
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
} from "../types";
import { apiClient } from "@/lib/api/client";
// 카테고리 컬럼 타입 (table_column_category_values 용)
interface CategoryColumnOption {
tableName: string;
columnName: string;
columnLabel: string;
valueCount: number;
// 조합키: tableName.columnName
key: string;
}
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -49,6 +60,10 @@ export function FieldDetailSettingsModal({
// 로컬 상태로 필드 설정 관리
const [localField, setLocalField] = useState<FormFieldConfig>(field);
// 전체 카테고리 컬럼 목록 상태
const [categoryColumns, setCategoryColumns] = useState<CategoryColumnOption[]>([]);
const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false);
// open이 변경될 때마다 필드 데이터 동기화
useEffect(() => {
if (open) {
@ -56,6 +71,49 @@ export function FieldDetailSettingsModal({
}
}, [open, field]);
// 모든 카테고리 컬럼 목록 로드 (모달 열릴 때)
useEffect(() => {
const loadAllCategoryColumns = async () => {
if (!open) return;
setLoadingCategoryColumns(true);
try {
// /api/table-categories/all-columns API 호출
const response = await apiClient.get("/table-categories/all-columns");
if (response.data?.success && response.data?.data) {
// 중복 제거를 위해 Map 사용
const uniqueMap = new Map<string, CategoryColumnOption>();
response.data.data.forEach((col: any) => {
const tableName = col.tableName || col.table_name;
const columnName = col.columnName || col.column_name;
const key = `${tableName}.${columnName}`;
// 이미 존재하는 경우 valueCount가 더 큰 것을 유지
if (!uniqueMap.has(key)) {
uniqueMap.set(key, {
tableName,
columnName,
columnLabel: col.columnLabel || col.column_label || columnName,
valueCount: parseInt(col.valueCount || col.value_count || "0"),
key,
});
}
});
setCategoryColumns(Array.from(uniqueMap.values()));
} else {
setCategoryColumns([]);
}
} catch (error) {
setCategoryColumns([]);
} finally {
setLoadingCategoryColumns(false);
}
};
loadAllCategoryColumns();
}, [open]);
// 필드 업데이트 함수
const updateField = (updates: Partial<FormFieldConfig>) => {
setLocalField((prev) => ({ ...prev, ...updates }));
@ -107,7 +165,7 @@ export function FieldDetailSettingsModal({
});
};
// 소스 테이블 컬럼 목록
// 소스 테이블 컬럼 목록 (연결 필드용)
const sourceTableColumns = localField.linkedFieldGroup?.sourceTable
? tableColumns[localField.linkedFieldGroup.sourceTable] || []
: [];
@ -248,7 +306,7 @@ export function FieldDetailSettingsModal({
<span>Select </span>
{localField.selectOptions?.type && (
<span className="text-[9px] text-muted-foreground">
({localField.selectOptions.type === "table" ? "테이블 참조" : localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"})
({localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"})
</span>
)}
</div>
@ -264,7 +322,7 @@ export function FieldDetailSettingsModal({
updateField({
selectOptions: {
...localField.selectOptions,
type: value as "static" | "table" | "code",
type: value as "static" | "code",
},
})
}
@ -463,23 +521,32 @@ export function FieldDetailSettingsModal({
{localField.selectOptions?.type === "code" && (
<div className="space-y-2 pt-2 border-t">
<HelpText>공통코드: 시스템 .</HelpText>
<HelpText>공통코드: 코드설정에서 .</HelpText>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localField.selectOptions?.codeCategory || ""}
onChange={(e) =>
<Label className="text-[10px]"> </Label>
<Select
value={localField.selectOptions?.categoryKey || ""}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
codeCategory: e.target.value,
categoryKey: value,
},
})
}
placeholder="DEPT_TYPE"
className="h-7 text-xs mt-1"
/>
<HelpText> (: DEPT_TYPE, USER_STATUS)</HelpText>
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder={loadingCategoryColumns ? "로딩 중..." : "카테고리 선택"} />
</SelectTrigger>
<SelectContent>
{categoryColumns.map((col, idx) => (
<SelectItem key={`${col.key}-${idx}`} value={col.key}>
{col.columnLabel} - {col.tableName} ({col.valueCount})
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> </HelpText>
</div>
</div>
)}
@ -841,3 +908,4 @@ export function FieldDetailSettingsModal({
}

View File

@ -795,3 +795,4 @@ export function SaveSettingsModal({
}

View File

@ -13,8 +13,8 @@ import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormSectionConfig, FormFieldConfig, FIELD_TYPE_OPTIONS } from "../types";
import { defaultFieldConfig, generateFieldId } from "../config";
import { FormSectionConfig, FormFieldConfig, OptionalFieldGroupConfig, FIELD_TYPE_OPTIONS } from "../types";
import { defaultFieldConfig, generateFieldId, generateUniqueId } from "../config";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -36,6 +36,7 @@ export function SectionLayoutModal({
onSave,
onOpenFieldDetail,
}: SectionLayoutModalProps) {
// 로컬 상태로 섹션 관리
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
@ -46,6 +47,7 @@ export function SectionLayoutModal({
}
}, [open, section]);
// 섹션 업데이트 함수
const updateSection = (updates: Partial<FormSectionConfig>) => {
setLocalSection((prev) => ({ ...prev, ...updates }));
@ -497,6 +499,427 @@ export function SectionLayoutModal({
</div>
)}
</div>
{/* 옵셔널 필드 그룹 */}
<div className="space-y-3 border rounded-lg p-3 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-xs font-semibold"> </h3>
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
{localSection.optionalFieldGroups?.length || 0}
</Badge>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
const newGroup: OptionalFieldGroupConfig = {
id: generateUniqueId("optgroup"),
title: `옵셔널 그룹 ${(localSection.optionalFieldGroups?.length || 0) + 1}`,
fields: [],
};
updateSection({
optionalFieldGroups: [...(localSection.optionalFieldGroups || []), newGroup],
});
}}
className="h-7 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<HelpText>
"추가" .
<br />
: 해외 (, , )
</HelpText>
{(!localSection.optionalFieldGroups || localSection.optionalFieldGroups.length === 0) ? (
<div className="text-center py-6 border border-dashed rounded-lg">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-3">
{localSection.optionalFieldGroups.map((group, groupIndex) => (
<div key={group.id} className="border rounded-lg p-3 bg-muted/30">
{/* 그룹 헤더 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[9px]"> {groupIndex + 1}</Badge>
<span className="text-xs font-medium">{group.title}</span>
<Badge variant="secondary" className="text-[8px] px-1">
{group.fields.length}
</Badge>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
updateSection({
optionalFieldGroups: localSection.optionalFieldGroups?.filter((g) => g.id !== group.id),
});
}}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 그룹 기본 설정 */}
<div className="space-y-2">
{/* 제목 및 설명 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[9px]"> </Label>
<Input
value={group.title}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, title: e.target.value } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="mt-0.5 h-6 text-[9px]"
placeholder="예: 해외 판매 정보"
/>
</div>
<div>
<Label className="text-[9px]"> </Label>
<Input
value={group.description || ""}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, description: e.target.value } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="mt-0.5 h-6 text-[9px]"
placeholder="해외 판매 시 추가 정보 입력"
/>
</div>
</div>
{/* 레이아웃 및 옵션 */}
<div className="grid grid-cols-4 gap-2">
<div>
<Label className="text-[8px]"> </Label>
<Select
value={String(group.columns || "inherit")}
onValueChange={(value) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id
? { ...g, columns: value === "inherit" ? undefined : parseInt(value) }
: g
);
updateSection({ optionalFieldGroups: newGroups });
}}
>
<SelectTrigger className="mt-0.5 h-5 text-[8px]">
<SelectValue placeholder="상속" />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit"></SelectItem>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-1 pb-0.5">
<Switch
checked={group.collapsible || false}
onCheckedChange={(checked) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, collapsible: checked } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="scale-50"
/>
<span className="text-[8px]"> </span>
</div>
<div className="flex items-end gap-1 pb-0.5">
<Switch
checked={group.defaultCollapsed || false}
onCheckedChange={(checked) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, defaultCollapsed: checked } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
disabled={!group.collapsible}
className="scale-50"
/>
<span className="text-[8px]"> </span>
</div>
<div className="flex items-end gap-1 pb-0.5">
<Switch
checked={group.confirmRemove || false}
onCheckedChange={(checked) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, confirmRemove: checked } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="scale-50"
/>
<span className="text-[8px]"> </span>
</div>
</div>
<Separator className="my-2" />
{/* 버튼 텍스트 설정 */}
<div>
<Label className="text-[9px] font-medium"> </Label>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[8px]"> </Label>
<Input
value={group.addButtonText || ""}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, addButtonText: e.target.value } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="mt-0.5 h-5 text-[8px]"
placeholder="+ 해외 판매 설정 추가"
/>
</div>
<div>
<Label className="text-[8px]"> </Label>
<Input
value={group.removeButtonText || ""}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, removeButtonText: e.target.value } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="mt-0.5 h-5 text-[8px]"
placeholder="제거"
/>
</div>
</div>
<Separator className="my-2" />
{/* 연동 필드 설정 */}
<div>
<Label className="text-[9px] font-medium"> ()</Label>
<HelpText>/ </HelpText>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-[8px]"> ()</Label>
<Input
value={group.triggerField || ""}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, triggerField: e.target.value } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="mt-0.5 h-5 text-[8px]"
placeholder="sales_type"
/>
</div>
<div>
<Label className="text-[8px]"> </Label>
<Input
value={group.triggerValueOnAdd || ""}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, triggerValueOnAdd: e.target.value } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="mt-0.5 h-5 text-[8px]"
placeholder="해외"
/>
</div>
<div>
<Label className="text-[8px]"> </Label>
<Input
value={group.triggerValueOnRemove || ""}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, triggerValueOnRemove: e.target.value } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="mt-0.5 h-5 text-[8px]"
placeholder="국내"
/>
</div>
</div>
<Separator className="my-2" />
{/* 그룹 내 필드 목록 */}
<div className="flex items-center justify-between">
<Label className="text-[9px] font-medium"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newField: FormFieldConfig = {
...defaultFieldConfig,
id: generateFieldId(),
label: `필드 ${group.fields.length + 1}`,
columnName: `field_${group.fields.length + 1}`,
};
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id ? { ...g, fields: [...g.fields, newField] } : g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="h-5 text-[8px] px-1.5"
>
<Plus className="h-2.5 w-2.5 mr-0.5" />
</Button>
</div>
{group.fields.length === 0 ? (
<div className="text-center py-3 border border-dashed rounded">
<p className="text-[9px] text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-1">
{group.fields.map((field, fieldIndex) => (
<div key={field.id} className="flex items-center gap-2 bg-white/50 rounded p-1.5">
<div className="flex-1 grid grid-cols-4 gap-1">
<Input
value={field.label}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id
? {
...g,
fields: g.fields.map((f) =>
f.id === field.id ? { ...f, label: e.target.value } : f
),
}
: g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="h-5 text-[8px]"
placeholder="라벨"
/>
<Input
value={field.columnName}
onChange={(e) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id
? {
...g,
fields: g.fields.map((f) =>
f.id === field.id ? { ...f, columnName: e.target.value } : f
),
}
: g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="h-5 text-[8px]"
placeholder="컬럼명"
/>
<Select
value={field.fieldType}
onValueChange={(value) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id
? {
...g,
fields: g.fields.map((f) =>
f.id === field.id
? { ...f, fieldType: value as FormFieldConfig["fieldType"] }
: f
),
}
: g
);
updateSection({ optionalFieldGroups: newGroups });
}}
>
<SelectTrigger className="h-5 text-[8px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={String(field.gridSpan || 3)}
onValueChange={(value) => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id
? {
...g,
fields: g.fields.map((f) =>
f.id === field.id ? { ...f, gridSpan: parseInt(value) } : f
),
}
: g
);
updateSection({ optionalFieldGroups: newGroups });
}}
>
<SelectTrigger className="h-5 text-[8px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">1/4</SelectItem>
<SelectItem value="4">1/3</SelectItem>
<SelectItem value="6">1/2</SelectItem>
<SelectItem value="12"></SelectItem>
</SelectContent>
</Select>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleOpenFieldDetail(field)}
className="h-5 px-1.5 text-[8px]"
>
<SettingsIcon className="h-2.5 w-2.5 mr-0.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newGroups = localSection.optionalFieldGroups?.map((g) =>
g.id === group.id
? { ...g, fields: g.fields.filter((f) => f.id !== field.id) }
: g
);
updateSection({ optionalFieldGroups: newGroups });
}}
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-2.5 w-2.5" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</ScrollArea>
</div>

View File

@ -16,8 +16,9 @@ export interface SelectOptionConfig {
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
filterCondition?: string;
// 공통코드 기반 옵션
codeCategory?: string;
// 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블)
// 형식: "tableName.columnName" (예: "sales_order_mng.incoterms")
categoryKey?: string;
}
// 채번규칙 설정
@ -153,6 +154,29 @@ export interface RepeatSectionConfig {
confirmRemove?: boolean; // 삭제 시 확인 (기본: false)
}
// 옵셔널 필드 그룹 설정 (섹션 내에서 추가 버튼으로 표시되는 필드 그룹)
export interface OptionalFieldGroupConfig {
id: string; // 그룹 고유 ID
fields: FormFieldConfig[]; // 그룹에 포함된 필드들
// 섹션 스타일 설정 (활성화 시 표시되는 영역)
title: string; // 그룹 제목 (예: "해외 판매 정보")
description?: string; // 그룹 설명
columns?: number; // 필드 배치 컬럼 수 (기본: 부모 섹션 columns 상속)
collapsible?: boolean; // 접을 수 있는지 (기본: false)
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
// 버튼 설정
addButtonText?: string; // 추가 버튼 텍스트 (기본: "+ {title} 추가")
removeButtonText?: string; // 제거 버튼 텍스트 (기본: "제거")
confirmRemove?: boolean; // 제거 시 확인 (기본: false)
// 연동 필드 설정 (추가/제거 시 다른 필드 값 변경)
triggerField?: string; // 값을 변경할 필드 (columnName)
triggerValueOnAdd?: any; // 추가 시 설정할 값 (예: "해외")
triggerValueOnRemove?: any; // 제거 시 설정할 값 (예: "국내")
}
// 섹션 설정
export interface FormSectionConfig {
id: string;
@ -166,6 +190,9 @@ export interface FormSectionConfig {
repeatable?: boolean;
repeatConfig?: RepeatSectionConfig;
// 옵셔널 필드 그룹 (섹션 내에서 추가 버튼으로 표시)
optionalFieldGroups?: OptionalFieldGroupConfig[];
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
linkedFieldGroups?: LinkedFieldGroup[];

View File

@ -973,6 +973,20 @@ export class ButtonActionExecutor {
itemCount: parsedData.length,
});
// 🆕 범용 폼 모달의 공통 필드 추출 (order_no, manager_id 등)
// "범용_폼_모달" 키에서 공통 필드를 가져옴
const universalFormData = context.formData["범용_폼_모달"] as Record<string, unknown> | undefined;
const commonFields: Record<string, unknown> = {};
if (universalFormData && typeof universalFormData === "object") {
// 공통 필드 복사 (내부 메타 필드 제외)
for (const [key, value] of Object.entries(universalFormData)) {
if (!key.startsWith("_") && !key.endsWith("_numberingRuleId") && value !== undefined && value !== "") {
commonFields[key] = value;
}
}
console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields);
}
for (const item of parsedData) {
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
@ -990,9 +1004,11 @@ export class ButtonActionExecutor {
delete dataToSave.id;
}
// 사용자 정보 추가
// 🆕 공통 필드 병합 + 사용자 정보 추가
// 공통 필드를 먼저 넣고, 개별 항목 데이터로 덮어씀 (개별 항목이 우선)
const dataWithMeta: Record<string, unknown> = {
...dataToSave,
...commonFields, // 범용 폼 모달의 공통 필드 (order_no, manager_id 등)
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,