feat(universal-form-modal): 옵셔널 필드 그룹 및 카테고리 Select 옵션 기능 추가
- 옵셔널 필드 그룹: 섹션 내 선택적 필드 그룹 지원 (추가/제거, 연동 필드 자동 변경) - 카테고리 Select: table_column_category_values 테이블 값을 Select 옵션으로 사용 - 전체 카테고리 컬럼 조회 API: GET /api/table-categories/all-columns - RepeaterFieldGroup 저장 시 공통 필드 자동 병합
This commit is contained in:
parent
31746e8a0b
commit
ccbbf46faf
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -877,7 +877,17 @@ export async function addTableData(
|
||||||
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||||
if (hasCompanyCodeColumn) {
|
if (hasCompanyCodeColumn) {
|
||||||
data.company_code = companyCode;
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
getCategoryColumns,
|
getCategoryColumns,
|
||||||
|
getAllCategoryColumns,
|
||||||
getCategoryValues,
|
getCategoryValues,
|
||||||
addCategoryValue,
|
addCategoryValue,
|
||||||
updateCategoryValue,
|
updateCategoryValue,
|
||||||
|
|
@ -22,6 +23,10 @@ const router = Router();
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||||
|
// 주의: 더 구체적인 라우트보다 먼저 와야 함
|
||||||
|
router.get("/all-columns", getAllCategoryColumns);
|
||||||
|
|
||||||
// 테이블의 카테고리 컬럼 목록 조회
|
// 테이블의 카테고리 컬럼 목록 조회
|
||||||
router.get("/:tableName/columns", getCategoryColumns);
|
router.get("/:tableName/columns", getCategoryColumns);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,11 +86,12 @@ export class CommonCodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||||
|
// company_code = '*'인 공통 데이터도 함께 조회
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
|
||||||
values.push(userCompanyCode);
|
values.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
|
||||||
} else if (userCompanyCode === "*") {
|
} else if (userCompanyCode === "*") {
|
||||||
// 최고 관리자는 모든 데이터 조회 가능
|
// 최고 관리자는 모든 데이터 조회 가능
|
||||||
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
||||||
|
|
@ -116,7 +117,7 @@ export class CommonCodeService {
|
||||||
|
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
// 카테고리 조회
|
// code_category 테이블에서만 조회 (comm_code 제거)
|
||||||
const categories = await query<CodeCategory>(
|
const categories = await query<CodeCategory>(
|
||||||
`SELECT * FROM code_category
|
`SELECT * FROM code_category
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -134,7 +135,7 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
`카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -224,7 +225,7 @@ export class CommonCodeService {
|
||||||
paramIndex,
|
paramIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 코드 조회
|
// code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
|
||||||
const codes = await query<CodeInfo>(
|
const codes = await query<CodeInfo>(
|
||||||
`SELECT * FROM code_info
|
`SELECT * FROM code_info
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -242,20 +243,9 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
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 };
|
return { data: codes, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -101,3 +101,4 @@
|
||||||
- [split-panel-layout (v1)](../split-panel-layout/README.md)
|
- [split-panel-layout (v1)](../split-panel-layout/README.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer
|
||||||
SplitPanelLayout2Renderer.registerSelf();
|
SplitPanelLayout2Renderer.registerSelf();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
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 { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
FormDataState,
|
FormDataState,
|
||||||
RepeatSectionItem,
|
RepeatSectionItem,
|
||||||
SelectOptionConfig,
|
SelectOptionConfig,
|
||||||
|
OptionalFieldGroupConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { defaultConfig, generateUniqueId } from "./config";
|
import { defaultConfig, generateUniqueId } from "./config";
|
||||||
|
|
||||||
|
|
@ -177,6 +178,9 @@ export function UniversalFormModalComponent({
|
||||||
// 섹션 접힘 상태
|
// 섹션 접힘 상태
|
||||||
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
|
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 활성화 상태 (섹션ID-그룹ID 조합)
|
||||||
|
const [activatedOptionalFieldGroups, setActivatedOptionalFieldGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Select 옵션 캐시
|
// Select 옵션 캐시
|
||||||
const [selectOptionsCache, setSelectOptionsCache] = useState<{
|
const [selectOptionsCache, setSelectOptionsCache] = useState<{
|
||||||
[key: string]: { value: string; label: string }[];
|
[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 옵션 로드
|
// Select 옵션 로드
|
||||||
const loadSelectOptions = useCallback(
|
const loadSelectOptions = useCallback(
|
||||||
async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => {
|
async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => {
|
||||||
|
|
@ -587,9 +634,10 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (optionConfig.type === "static") {
|
if (optionConfig.type === "static") {
|
||||||
|
// 직접 입력: 설정된 정적 옵션 사용
|
||||||
options = optionConfig.staticOptions || [];
|
options = optionConfig.staticOptions || [];
|
||||||
} else if (optionConfig.type === "table" && optionConfig.tableName) {
|
} else if (optionConfig.type === "table" && optionConfig.tableName) {
|
||||||
// POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
|
// 테이블 참조: POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
|
||||||
const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, {
|
const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
|
@ -613,13 +661,21 @@ export function UniversalFormModalComponent({
|
||||||
value: String(row[optionConfig.valueColumn || "id"]),
|
value: String(row[optionConfig.valueColumn || "id"]),
|
||||||
label: String(row[optionConfig.labelColumn || "name"]),
|
label: String(row[optionConfig.labelColumn || "name"]),
|
||||||
}));
|
}));
|
||||||
} else if (optionConfig.type === "code" && optionConfig.codeCategory) {
|
} else if (optionConfig.type === "code" && optionConfig.categoryKey) {
|
||||||
const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
|
// 공통코드(카테고리 컬럼): table_column_category_values 테이블에서 조회
|
||||||
if (response.data?.success && response.data?.data) {
|
// categoryKey 형식: "tableName.columnName"
|
||||||
options = response.data.data.map((code: any) => ({
|
const [categoryTable, categoryColumn] = optionConfig.categoryKey.split(".");
|
||||||
value: code.code_value || code.codeValue,
|
if (categoryTable && categoryColumn) {
|
||||||
label: code.code_name || code.codeName,
|
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>
|
</div>
|
||||||
|
|
||||||
|
{/* 옵셔널 필드 그룹 렌더링 */}
|
||||||
|
{section.optionalFieldGroups && section.optionalFieldGroups.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{section.optionalFieldGroups.map((group) =>
|
||||||
|
renderOptionalFieldGroup(section, group, sectionColumns)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</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 renderRepeatableSection = (section: FormSectionConfig, isCollapsed: boolean) => {
|
||||||
const items = repeatSections[section.id] || [];
|
const items = repeatSections[section.id] || [];
|
||||||
|
|
|
||||||
|
|
@ -499,7 +499,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
onOpenChange={setSectionLayoutModalOpen}
|
onOpenChange={setSectionLayoutModalOpen}
|
||||||
section={selectedSection}
|
section={selectedSection}
|
||||||
onSave={(updates) => {
|
onSave={(updates) => {
|
||||||
|
// config 업데이트
|
||||||
updateSection(selectedSection.id, updates);
|
updateSection(selectedSection.id, updates);
|
||||||
|
// selectedSection 상태도 업데이트 (최신 상태 유지)
|
||||||
|
setSelectedSection({ ...selectedSection, ...updates });
|
||||||
setSectionLayoutModalOpen(false);
|
setSectionLayoutModalOpen(false);
|
||||||
}}
|
}}
|
||||||
onOpenFieldDetail={(field) => {
|
onOpenFieldDetail={(field) => {
|
||||||
|
|
@ -522,18 +525,30 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
field={selectedField}
|
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({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
sections: config.sections.map((s) =>
|
sections: config.sections.map((s) =>
|
||||||
s.id === selectedSection.id
|
s.id === selectedSection.id ? updatedSection : s
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
fields: s.fields.map((f) => (f.id === selectedField.id ? { ...f, ...updates } : f)),
|
|
||||||
}
|
|
||||||
: s,
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// selectedSection과 selectedField 상태도 업데이트 (다음에 다시 열었을 때 최신 값 반영)
|
||||||
|
setSelectedSection(updatedSection);
|
||||||
|
setSelectedField(updatedField as FormFieldConfig);
|
||||||
setFieldDetailModalOpen(false);
|
setFieldDetailModalOpen(false);
|
||||||
setSectionLayoutModalOpen(true);
|
setSectionLayoutModalOpen(true);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,30 @@ export const defaultSectionConfig = {
|
||||||
itemTitle: "항목 {index}",
|
itemTitle: "항목 {index}",
|
||||||
confirmRemove: false,
|
confirmRemove: false,
|
||||||
},
|
},
|
||||||
|
optionalFieldGroups: [],
|
||||||
linkedFieldGroups: [],
|
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 = {
|
export const defaultLinkedFieldGroupConfig = {
|
||||||
id: "",
|
id: "",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,17 @@ import {
|
||||||
SELECT_OPTION_TYPE_OPTIONS,
|
SELECT_OPTION_TYPE_OPTIONS,
|
||||||
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
||||||
} from "../types";
|
} 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 }) => (
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|
@ -48,6 +59,10 @@ export function FieldDetailSettingsModal({
|
||||||
}: FieldDetailSettingsModalProps) {
|
}: FieldDetailSettingsModalProps) {
|
||||||
// 로컬 상태로 필드 설정 관리
|
// 로컬 상태로 필드 설정 관리
|
||||||
const [localField, setLocalField] = useState<FormFieldConfig>(field);
|
const [localField, setLocalField] = useState<FormFieldConfig>(field);
|
||||||
|
|
||||||
|
// 전체 카테고리 컬럼 목록 상태
|
||||||
|
const [categoryColumns, setCategoryColumns] = useState<CategoryColumnOption[]>([]);
|
||||||
|
const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false);
|
||||||
|
|
||||||
// open이 변경될 때마다 필드 데이터 동기화
|
// open이 변경될 때마다 필드 데이터 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -55,6 +70,49 @@ export function FieldDetailSettingsModal({
|
||||||
setLocalField(field);
|
setLocalField(field);
|
||||||
}
|
}
|
||||||
}, [open, field]);
|
}, [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>) => {
|
const updateField = (updates: Partial<FormFieldConfig>) => {
|
||||||
|
|
@ -107,7 +165,7 @@ export function FieldDetailSettingsModal({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 소스 테이블 컬럼 목록
|
// 소스 테이블 컬럼 목록 (연결 필드용)
|
||||||
const sourceTableColumns = localField.linkedFieldGroup?.sourceTable
|
const sourceTableColumns = localField.linkedFieldGroup?.sourceTable
|
||||||
? tableColumns[localField.linkedFieldGroup.sourceTable] || []
|
? tableColumns[localField.linkedFieldGroup.sourceTable] || []
|
||||||
: [];
|
: [];
|
||||||
|
|
@ -248,7 +306,7 @@ export function FieldDetailSettingsModal({
|
||||||
<span>Select 옵션 설정</span>
|
<span>Select 옵션 설정</span>
|
||||||
{localField.selectOptions?.type && (
|
{localField.selectOptions?.type && (
|
||||||
<span className="text-[9px] text-muted-foreground">
|
<span className="text-[9px] text-muted-foreground">
|
||||||
({localField.selectOptions.type === "table" ? "테이블 참조" : localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"})
|
({localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,7 +322,7 @@ export function FieldDetailSettingsModal({
|
||||||
updateField({
|
updateField({
|
||||||
selectOptions: {
|
selectOptions: {
|
||||||
...localField.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" && (
|
{localField.selectOptions?.type === "code" && (
|
||||||
<div className="space-y-2 pt-2 border-t">
|
<div className="space-y-2 pt-2 border-t">
|
||||||
<HelpText>공통코드: 시스템 공통코드에서 옵션을 가져옵니다.</HelpText>
|
<HelpText>공통코드: 코드설정에서 등록한 카테고리 값을 가져옵니다.</HelpText>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">코드 카테고리</Label>
|
<Label className="text-[10px]">카테고리 선택</Label>
|
||||||
<Input
|
<Select
|
||||||
value={localField.selectOptions?.codeCategory || ""}
|
value={localField.selectOptions?.categoryKey || ""}
|
||||||
onChange={(e) =>
|
onValueChange={(value) =>
|
||||||
updateField({
|
updateField({
|
||||||
selectOptions: {
|
selectOptions: {
|
||||||
...localField.selectOptions,
|
...localField.selectOptions,
|
||||||
codeCategory: e.target.value,
|
categoryKey: value,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="DEPT_TYPE"
|
>
|
||||||
className="h-7 text-xs mt-1"
|
<SelectTrigger className="h-7 text-xs mt-1">
|
||||||
/>
|
<SelectValue placeholder={loadingCategoryColumns ? "로딩 중..." : "카테고리 선택"} />
|
||||||
<HelpText>공통코드 카테고리를 입력하세요 (예: DEPT_TYPE, USER_STATUS)</HelpText>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -841,3 +908,4 @@ export function FieldDetailSettingsModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -795,3 +795,4 @@ export function SaveSettingsModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react";
|
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FormSectionConfig, FormFieldConfig, FIELD_TYPE_OPTIONS } from "../types";
|
import { FormSectionConfig, FormFieldConfig, OptionalFieldGroupConfig, FIELD_TYPE_OPTIONS } from "../types";
|
||||||
import { defaultFieldConfig, generateFieldId } from "../config";
|
import { defaultFieldConfig, generateFieldId, generateUniqueId } from "../config";
|
||||||
|
|
||||||
// 도움말 텍스트 컴포넌트
|
// 도움말 텍스트 컴포넌트
|
||||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|
@ -36,6 +36,7 @@ export function SectionLayoutModal({
|
||||||
onSave,
|
onSave,
|
||||||
onOpenFieldDetail,
|
onOpenFieldDetail,
|
||||||
}: SectionLayoutModalProps) {
|
}: SectionLayoutModalProps) {
|
||||||
|
|
||||||
// 로컬 상태로 섹션 관리
|
// 로컬 상태로 섹션 관리
|
||||||
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
|
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ export function SectionLayoutModal({
|
||||||
}
|
}
|
||||||
}, [open, section]);
|
}, [open, section]);
|
||||||
|
|
||||||
|
|
||||||
// 섹션 업데이트 함수
|
// 섹션 업데이트 함수
|
||||||
const updateSection = (updates: Partial<FormSectionConfig>) => {
|
const updateSection = (updates: Partial<FormSectionConfig>) => {
|
||||||
setLocalSection((prev) => ({ ...prev, ...updates }));
|
setLocalSection((prev) => ({ ...prev, ...updates }));
|
||||||
|
|
@ -497,6 +499,427 @@ export function SectionLayoutModal({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ export interface SelectOptionConfig {
|
||||||
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
|
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
|
||||||
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
|
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
|
||||||
filterCondition?: string;
|
filterCondition?: string;
|
||||||
// 공통코드 기반 옵션
|
// 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블)
|
||||||
codeCategory?: string;
|
// 형식: "tableName.columnName" (예: "sales_order_mng.incoterms")
|
||||||
|
categoryKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 채번규칙 설정
|
// 채번규칙 설정
|
||||||
|
|
@ -153,6 +154,29 @@ export interface RepeatSectionConfig {
|
||||||
confirmRemove?: boolean; // 삭제 시 확인 (기본: false)
|
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 {
|
export interface FormSectionConfig {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -166,6 +190,9 @@ export interface FormSectionConfig {
|
||||||
repeatable?: boolean;
|
repeatable?: boolean;
|
||||||
repeatConfig?: RepeatSectionConfig;
|
repeatConfig?: RepeatSectionConfig;
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 (섹션 내에서 추가 버튼으로 표시)
|
||||||
|
optionalFieldGroups?: OptionalFieldGroupConfig[];
|
||||||
|
|
||||||
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
|
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
|
||||||
linkedFieldGroups?: LinkedFieldGroup[];
|
linkedFieldGroups?: LinkedFieldGroup[];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -973,6 +973,20 @@ export class ButtonActionExecutor {
|
||||||
itemCount: parsedData.length,
|
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) {
|
for (const item of parsedData) {
|
||||||
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
||||||
|
|
||||||
|
|
@ -990,9 +1004,11 @@ export class ButtonActionExecutor {
|
||||||
delete dataToSave.id;
|
delete dataToSave.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 정보 추가
|
// 🆕 공통 필드 병합 + 사용자 정보 추가
|
||||||
|
// 공통 필드를 먼저 넣고, 개별 항목 데이터로 덮어씀 (개별 항목이 우선)
|
||||||
const dataWithMeta: Record<string, unknown> = {
|
const dataWithMeta: Record<string, unknown> = {
|
||||||
...dataToSave,
|
...commonFields, // 범용 폼 모달의 공통 필드 (order_no, manager_id 등)
|
||||||
|
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
|
||||||
created_by: context.userId,
|
created_by: context.userId,
|
||||||
updated_by: context.userId,
|
updated_by: context.userId,
|
||||||
company_code: context.companyCode,
|
company_code: context.companyCode,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue