Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng

This commit is contained in:
dohyeons 2025-12-18 13:27:25 +09:00
commit ac8961160d
36 changed files with 1173 additions and 132 deletions

View File

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

View File

@ -767,11 +767,12 @@ export async function getTableData(
const tableManagementService = new TableManagementService();
// 🆕 현재 사용자 필터 적용
// 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
let enhancedSearch = { ...search };
if (autoFilter?.enabled && req.user) {
const filterColumn = autoFilter.filterColumn || "company_code";
const userField = autoFilter.userField || "companyCode";
const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
if (shouldApplyAutoFilter && req.user) {
const filterColumn = autoFilter?.filterColumn || "company_code";
const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField];
if (userValue) {
@ -877,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}`);
}
}

View File

@ -52,3 +52,4 @@ export default router;

View File

@ -48,3 +48,4 @@ export default router;

View File

@ -64,3 +64,4 @@ export default router;

View File

@ -52,3 +52,4 @@ export default router;

View File

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

View File

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

View File

@ -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);

View File

@ -186,8 +186,13 @@ export class EntityJoinService {
}
}
// 별칭 컬럼명 생성 (writer -> writer_name)
const aliasColumn = `${column.column_name}_name`;
// 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
// 단일 컬럼: manager + user_name → manager_user_name
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
const firstDisplayColumn = displayColumns[0] || "name";
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn}${aliasColumn}`);
const joinConfig: EntityJoinConfig = {
sourceTable: tableName,

View File

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

View File

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

View File

@ -584,3 +584,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -357,3 +357,4 @@

View File

@ -343,3 +343,4 @@ const getComponentValue = (componentId: string) => {
3. **조건부 저장**: 특정 조건 만족 시에만 저장
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장

View File

@ -93,7 +93,7 @@ export default function TableManagementPage() {
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
// 테이블 복제 관련 상태
const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create");
const [duplicateSourceTable, setDuplicateSourceTable] = useState<string | null>(null);
@ -109,7 +109,7 @@ export default function TableManagementPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>("");
const [isDeleting, setIsDeleting] = useState(false);
// 선택된 테이블 목록 (체크박스)
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
@ -459,11 +459,39 @@ export default function TableManagementPage() {
if (!selectedTable) return;
try {
// 🎯 Entity 타입인 경우 detailSettings에 엔티티 설정을 JSON으로 포함
let finalDetailSettings = column.detailSettings || "";
if (column.inputType === "entity" && column.referenceTable) {
// 기존 detailSettings를 파싱하거나 새로 생성
let existingSettings: Record<string, unknown> = {};
if (typeof column.detailSettings === "string" && column.detailSettings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(column.detailSettings);
} catch {
existingSettings = {};
}
}
// 엔티티 설정 추가
const entitySettings = {
...existingSettings,
entityTable: column.referenceTable,
entityCodeColumn: column.referenceColumn || "id",
entityLabelColumn: column.displayColumn || "name",
placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요",
searchable: existingSettings.searchable ?? true,
};
finalDetailSettings = JSON.stringify(entitySettings);
console.log("🔧 Entity 설정 JSON 생성:", entitySettings);
}
const columnSetting = {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명
inputType: column.inputType || "text",
detailSettings: column.detailSettings || "",
detailSettings: finalDetailSettings,
codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "",
@ -487,7 +515,7 @@ export default function TableManagementPage() {
if (response.data.success) {
console.log("✅ 컬럼 설정 저장 성공");
// 🆕 Category 타입인 경우 컬럼 매핑 처리
console.log("🔍 카테고리 조건 체크:", {
isCategory: column.inputType === "category",
@ -547,7 +575,7 @@ export default function TableManagementPage() {
} else if (successCount > 0 && failCount > 0) {
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
} else if (failCount > 0) {
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.");
}
} else {
toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)");
@ -680,9 +708,7 @@ export default function TableManagementPage() {
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
if (totalSuccessCount > 0) {
toast.success(
`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`
);
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
} else if (totalFailCount > 0) {
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
} else {
@ -1000,14 +1026,15 @@ export default function TableManagementPage() {
.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
(table.displayName &&
table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
)
.every((table) => selectedTableIds.has(table.tableName))
}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
/>
<span className="text-sm text-muted-foreground">
<span className="text-muted-foreground text-sm">
{selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`}
</span>
</div>
@ -1047,9 +1074,9 @@ export default function TableManagementPage() {
<div
key={table.tableName}
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${
selectedTable === table.tableName
? "shadow-md bg-muted/30"
: "hover:shadow-lg hover:bg-muted/20"
selectedTable === table.tableName
? "bg-muted/30 shadow-md"
: "hover:bg-muted/20 hover:shadow-lg"
}`}
style={
selectedTable === table.tableName
@ -1068,10 +1095,7 @@ export default function TableManagementPage() {
onClick={(e) => e.stopPropagation()}
/>
)}
<div
className="flex-1 cursor-pointer"
onClick={() => handleTableSelect(table.tableName)}
>
<div className="flex-1 cursor-pointer" onClick={() => handleTableSelect(table.tableName)}>
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
<p className="text-muted-foreground mt-1 text-xs">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
@ -1147,7 +1171,10 @@ export default function TableManagementPage() {
) : (
<div className="flex flex-1 flex-col overflow-hidden">
{/* 컬럼 헤더 (고정) */}
<div className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
<div
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
>
<div className="pr-4"></div>
<div className="px-4"></div>
<div className="pr-6"> </div>
@ -1171,7 +1198,7 @@ export default function TableManagementPage() {
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
>
<div className="pr-4 pt-1">
<div className="pt-1 pr-4">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="px-4">
@ -1226,9 +1253,9 @@ export default function TableManagementPage() {
<label className="text-muted-foreground mb-1 block text-xs">
(2)
</label>
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3">
{secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
2 . .
</p>
) : (
@ -1236,7 +1263,7 @@ export default function TableManagementPage() {
// menuObjid를 숫자로 변환하여 비교
const menuObjidNum = Number(menu.menuObjid);
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
return (
<div key={menu.menuObjid} className="flex items-center gap-2">
<input
@ -1253,15 +1280,15 @@ export default function TableManagementPage() {
prev.map((col) =>
col.columnName === column.columnName
? { ...col, categoryMenus: newMenus }
: col
)
: col,
),
);
}}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2"
/>
<label
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
className="text-xs cursor-pointer flex-1"
className="flex-1 cursor-pointer text-xs"
>
{menu.parentMenuName} {menu.menuName}
</label>
@ -1282,9 +1309,7 @@ export default function TableManagementPage() {
<>
{/* 참조 테이블 */}
<div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs">
</label>
<label className="text-muted-foreground mb-1 block text-xs"> </label>
<Select
value={column.referenceTable || "none"}
onValueChange={(value) =>
@ -1296,15 +1321,10 @@ export default function TableManagementPage() {
</SelectTrigger>
<SelectContent>
{referenceTableOptions.map((option, index) => (
<SelectItem
key={`entity-${option.value}-${index}`}
value={option.value}
>
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-muted-foreground text-xs">
{option.value}
</span>
<span className="text-muted-foreground text-xs">{option.value}</span>
</div>
</SelectItem>
))}
@ -1315,9 +1335,7 @@ export default function TableManagementPage() {
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
<div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs">
</label>
<label className="text-muted-foreground mb-1 block text-xs"> </label>
<Select
value={column.referenceColumn || "none"}
onValueChange={(value) =>
@ -1361,9 +1379,7 @@ export default function TableManagementPage() {
column.referenceColumn &&
column.referenceColumn !== "none" && (
<div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs">
</label>
<label className="text-muted-foreground mb-1 block text-xs"> </label>
<Select
value={column.displayColumn || "none"}
onValueChange={(value) =>
@ -1408,7 +1424,7 @@ export default function TableManagementPage() {
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
<div className="bg-primary/10 text-primary flex w-48 items-center gap-1 rounded px-2 py-1 text-xs">
<span></span>
<span className="truncate"> </span>
</div>
@ -1460,9 +1476,10 @@ export default function TableManagementPage() {
setDuplicateSourceTable(null);
}}
onSuccess={async (result) => {
const message = duplicateModalMode === "duplicate"
? "테이블이 성공적으로 복제되었습니다!"
: "테이블이 성공적으로 생성되었습니다!";
const message =
duplicateModalMode === "duplicate"
? "테이블이 성공적으로 복제되었습니다!"
: "테이블이 성공적으로 생성되었습니다!";
toast.success(message);
// 테이블 목록 새로고침
await loadTables();
@ -1516,13 +1533,10 @@ export default function TableManagementPage() {
{selectedTableIds.size > 0 ? (
<>
<strong>{selectedTableIds.size}</strong> ?
<br />
.
<br /> .
</>
) : (
<>
? .
</>
<> ? .</>
)}
</DialogDescription>
</DialogHeader>
@ -1600,4 +1614,3 @@ export default function TableManagementPage() {
</div>
);
}

View File

@ -2201,12 +2201,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)];
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
const displayColor = categoryData?.color;
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
if (displayColor === "none") {
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>;
}

View File

@ -418,7 +418,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const valueStr = String(value); // 값을 문자열로 변환
const categoryData = mapping?.[valueStr];
const displayLabel = categoryData?.label || valueStr;
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
const displayColor = categoryData?.color;
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
fieldName: field.name,
@ -429,8 +429,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
displayColor,
});
// 색상이 "none"이면 일반 텍스트로 표시
if (displayColor === "none") {
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>;
}

View File

@ -137,3 +137,4 @@ export const useActiveTabOptional = () => {
return useContext(ActiveTabContext);
};

View File

@ -194,3 +194,4 @@ export function applyAutoFillToFormData(

View File

@ -426,12 +426,29 @@ export class DynamicFormApi {
sortBy?: string;
sortOrder?: "asc" | "desc";
filters?: Record<string, any>;
autoFilter?: {
enabled: boolean;
filterColumn?: string;
userField?: string;
};
},
): Promise<ApiResponse<any[]>> {
try {
console.log("📊 테이블 데이터 조회 요청:", { tableName, params });
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {});
// autoFilter가 없으면 기본값으로 멀티테넌시 필터 적용
// pageSize를 size로 변환 (백엔드 파라미터명 호환)
const requestParams = {
...params,
size: params?.pageSize || params?.size || 100, // 기본값 100
autoFilter: params?.autoFilter ?? {
enabled: true,
filterColumn: "company_code",
userField: "companyCode",
},
};
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, requestParams);
console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data);
console.log("🔍 response.data 상세:", {

View File

@ -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)
}

View File

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

View File

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

View File

@ -4104,13 +4104,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 선택된 컬럼들의 값을 구분자로 조합
const values = displayColumns
.map((colName: string) => {
// 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
let cellValue = rowData[colName];
// 🎯 백엔드 alias 규칙: ${sourceColumn}_${displayColumn}
// 예: manager 컬럼에서 user_name 선택 시 → manager_user_name
const joinedKey = `${column.columnName}_${colName}`;
let cellValue = rowData[joinedKey];
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
// fallback: 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
if (cellValue === null || cellValue === undefined) {
const joinedKey = `${column.columnName}_${colName}`;
cellValue = rowData[joinedKey];
cellValue = rowData[colName];
}
if (cellValue === null || cellValue === undefined) return "";
@ -4233,9 +4234,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (values.length === 1) {
const categoryData = mapping?.[values[0]];
const displayLabel = categoryData?.label || values[0];
const displayColor = categoryData?.color || "#64748b";
const displayColor = categoryData?.color;
if (displayColor === "none") {
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>;
}
@ -4258,9 +4260,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{values.map((val, idx) => {
const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val;
const displayColor = categoryData?.color || "#64748b";
const displayColor = categoryData?.color;
if (displayColor === "none") {
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return (
<span key={idx} className="text-sm">
{displayLabel}

View File

@ -19,7 +19,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -35,6 +35,7 @@ import {
FormDataState,
RepeatSectionItem,
SelectOptionConfig,
OptionalFieldGroupConfig,
} from "./types";
import { defaultConfig, generateUniqueId } from "./config";
@ -177,6 +178,9 @@ export function UniversalFormModalComponent({
// 섹션 접힘 상태
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
// 옵셔널 필드 그룹 활성화 상태 (섹션ID-그룹ID 조합)
const [activatedOptionalFieldGroups, setActivatedOptionalFieldGroups] = useState<Set<string>>(new Set());
// Select 옵션 캐시
const [selectOptionsCache, setSelectOptionsCache] = useState<{
[key: string]: { value: string; label: string }[];
@ -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] || [];

View File

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

View File

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

View File

@ -19,6 +19,17 @@ import {
SELECT_OPTION_TYPE_OPTIONS,
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
} from "../types";
import { apiClient } from "@/lib/api/client";
// 카테고리 컬럼 타입 (table_column_category_values 용)
interface CategoryColumnOption {
tableName: string;
columnName: string;
columnLabel: string;
valueCount: number;
// 조합키: tableName.columnName
key: string;
}
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
@ -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({
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1686,3 +1686,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -533,3 +533,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -520,3 +520,4 @@ function ScreenViewPage() {