Merge branch 'main' into feature/screen-management
This commit is contained in:
commit
7b30f6c7f2
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -878,7 +878,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -854,6 +854,11 @@ export class DynamicFormService {
|
|||
if (tableColumns.includes("updated_at")) {
|
||||
changedFields.updated_at = new Date();
|
||||
}
|
||||
// updated_date 컬럼도 지원 (sales_order_mng 등)
|
||||
if (tableColumns.includes("updated_date")) {
|
||||
changedFields.updated_date = new Date();
|
||||
console.log("📅 updated_date 자동 추가:", changedFields.updated_date);
|
||||
}
|
||||
|
||||
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -2289,6 +2289,13 @@ export class TableManagementService {
|
|||
|
||||
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
||||
|
||||
// created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가
|
||||
const hasCreatedDate = columnTypeMap.has("created_date");
|
||||
if (hasCreatedDate && !data.created_date) {
|
||||
data.created_date = new Date().toISOString();
|
||||
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||
}
|
||||
|
||||
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data).map((value, index) => {
|
||||
|
|
@ -2394,6 +2401,13 @@ export class TableManagementService {
|
|||
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
||||
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
|
||||
|
||||
// updated_date 컬럼이 있으면 자동으로 현재 시간 추가
|
||||
const hasUpdatedDate = columnTypeMap.has("updated_date");
|
||||
if (hasUpdatedDate && !updatedData.updated_date) {
|
||||
updatedData.updated_date = new Date().toISOString();
|
||||
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
||||
}
|
||||
|
||||
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
||||
const setConditions: string[] = [];
|
||||
const setValues: any[] = [];
|
||||
|
|
|
|||
|
|
@ -347,12 +347,23 @@ export function RepeaterTable({
|
|||
|
||||
// 계산 필드는 편집 불가
|
||||
if (column.calculated || !column.editable) {
|
||||
// 숫자 포맷팅 함수: 정수/소수점 자동 구분
|
||||
const formatNumber = (val: any): string => {
|
||||
if (val === undefined || val === null || val === "") return "0";
|
||||
const num = typeof val === "number" ? val : parseFloat(val);
|
||||
if (isNaN(num)) return "0";
|
||||
// 정수면 소수점 없이, 소수면 소수점 유지
|
||||
if (Number.isInteger(num)) {
|
||||
return num.toLocaleString("ko-KR");
|
||||
} else {
|
||||
return num.toLocaleString("ko-KR");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
{column.type === "number"
|
||||
? typeof value === "number"
|
||||
? value.toLocaleString()
|
||||
: value || "0"
|
||||
? formatNumber(value)
|
||||
: value || "-"}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -361,10 +372,23 @@ export function RepeaterTable({
|
|||
// 편집 가능한 필드
|
||||
switch (column.type) {
|
||||
case "number":
|
||||
// 숫자 표시: 정수/소수점 자동 구분
|
||||
const displayValue = (() => {
|
||||
if (value === undefined || value === null || value === "") return "";
|
||||
const num = typeof value === "number" ? value : parseFloat(value);
|
||||
if (isNaN(num)) return "";
|
||||
// 정수면 소수점 없이, 소수면 소수점 유지
|
||||
if (Number.isInteger(num)) {
|
||||
return num.toString();
|
||||
} else {
|
||||
return num.toString();
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value || ""}
|
||||
value={displayValue}
|
||||
onChange={(e) =>
|
||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,3 +101,4 @@
|
|||
- [split-panel-layout (v1)](../split-panel-layout/README.md)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,3 +41,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer
|
|||
SplitPanelLayout2Renderer.registerSelf();
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
|
|
@ -351,6 +355,7 @@ export function UniversalFormModalComponent({
|
|||
const newFormData: FormDataState = {};
|
||||
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
||||
const newCollapsed = new Set<string>();
|
||||
const newActivatedGroups = new Set<string>();
|
||||
|
||||
// 섹션별 초기화
|
||||
for (const section of config.sections) {
|
||||
|
|
@ -386,12 +391,47 @@ export function UniversalFormModalComponent({
|
|||
|
||||
newFormData[field.columnName] = value;
|
||||
}
|
||||
|
||||
// 옵셔널 필드 그룹 처리
|
||||
if (section.optionalFieldGroups) {
|
||||
for (const group of section.optionalFieldGroups) {
|
||||
const key = `${section.id}-${group.id}`;
|
||||
|
||||
// 수정 모드: triggerField 값이 triggerValueOnAdd와 일치하면 그룹 자동 활성화
|
||||
if (effectiveInitialData && group.triggerField && group.triggerValueOnAdd !== undefined) {
|
||||
const triggerValue = effectiveInitialData[group.triggerField];
|
||||
if (triggerValue === group.triggerValueOnAdd) {
|
||||
newActivatedGroups.add(key);
|
||||
console.log(`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`);
|
||||
|
||||
// 활성화된 그룹의 필드값도 초기화
|
||||
for (const field of group.fields) {
|
||||
let value = field.defaultValue ?? "";
|
||||
const parentField = field.parentFieldName || field.columnName;
|
||||
if (effectiveInitialData[parentField] !== undefined) {
|
||||
value = effectiveInitialData[parentField];
|
||||
}
|
||||
newFormData[field.columnName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 신규 등록 모드: triggerValueOnRemove를 기본값으로 설정
|
||||
if (group.triggerField && group.triggerValueOnRemove !== undefined) {
|
||||
// effectiveInitialData에 해당 값이 없는 경우에만 기본값 설정
|
||||
if (!effectiveInitialData || effectiveInitialData[group.triggerField] === undefined) {
|
||||
newFormData[group.triggerField] = group.triggerValueOnRemove;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFormData(newFormData);
|
||||
setRepeatSections(newRepeatSections);
|
||||
setCollapsedSections(newCollapsed);
|
||||
setActivatedOptionalFieldGroups(newActivatedGroups);
|
||||
setOriginalData(effectiveInitialData || {});
|
||||
|
||||
// 채번규칙 자동 생성
|
||||
|
|
@ -575,6 +615,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 +670,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 +697,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 +1592,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 +1608,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] || [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
@ -48,6 +59,10 @@ export function FieldDetailSettingsModal({
|
|||
}: FieldDetailSettingsModalProps) {
|
||||
// 로컬 상태로 필드 설정 관리
|
||||
const [localField, setLocalField] = useState<FormFieldConfig>(field);
|
||||
|
||||
// 전체 카테고리 컬럼 목록 상태
|
||||
const [categoryColumns, setCategoryColumns] = useState<CategoryColumnOption[]>([]);
|
||||
const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false);
|
||||
|
||||
// open이 변경될 때마다 필드 데이터 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -55,6 +70,49 @@ export function FieldDetailSettingsModal({
|
|||
setLocalField(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>) => {
|
||||
|
|
@ -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({
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -795,3 +795,4 @@ export function SaveSettingsModal({
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue