카테고리 구현

This commit is contained in:
kjs 2025-11-05 18:08:51 +09:00
parent f3bed0d713
commit bc029d1df8
23 changed files with 563 additions and 427 deletions

View File

@ -36,20 +36,11 @@ export const getCategoryValues = async (req: Request, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params; const { tableName, columnName } = req.params;
const menuId = parseInt(req.query.menuId as string, 10);
const includeInactive = req.query.includeInactive === "true"; const includeInactive = req.query.includeInactive === "true";
if (!menuId || isNaN(menuId)) {
return res.status(400).json({
success: false,
message: "menuId 파라미터가 필요합니다",
});
}
const values = await tableCategoryValueService.getCategoryValues( const values = await tableCategoryValueService.getCategoryValues(
tableName, tableName,
columnName, columnName,
menuId,
companyCode, companyCode,
includeInactive includeInactive
); );

View File

@ -6,54 +6,6 @@ import {
} from "../types/tableCategoryValue"; } from "../types/tableCategoryValue";
class TableCategoryValueService { class TableCategoryValueService {
/**
* ID
* ( )
*/
async getSiblingMenuIds(menuId: number): Promise<number[]> {
try {
const pool = getPool();
// 1. 현재 메뉴의 부모 ID 조회 (menu_info는 objid와 parent_obj_id 사용)
const parentQuery = `
SELECT parent_obj_id FROM menu_info WHERE objid = $1
`;
const parentResult = await pool.query(parentQuery, [menuId]);
if (parentResult.rows.length === 0) {
logger.warn(`메뉴 ID ${menuId}를 찾을 수 없습니다`);
return [menuId];
}
const parentId = parentResult.rows[0].parent_obj_id;
// 최상위 메뉴인 경우 (parent_obj_id가 null 또는 0)
if (!parentId || parentId === 0) {
logger.info(`메뉴 ${menuId}는 최상위 메뉴입니다`);
return [menuId];
}
// 2. 같은 부모를 가진 형제 메뉴들 조회
const siblingsQuery = `
SELECT objid FROM menu_info WHERE parent_obj_id = $1
`;
const siblingsResult = await pool.query(siblingsQuery, [parentId]);
const siblingIds = siblingsResult.rows.map((row) => Number(row.objid));
logger.info(`메뉴 ${menuId}의 형제 메뉴 ${siblingIds.length}개 조회`, {
menuId,
parentId,
siblings: siblingIds,
});
return siblingIds;
} catch (error: any) {
logger.error(`형제 메뉴 조회 실패: ${error.message}`);
// 에러 시 현재 메뉴만 반환
return [menuId];
}
}
/** /**
* *
*/ */
@ -98,12 +50,11 @@ class TableCategoryValueService {
} }
/** /**
* ( ) * ( )
*/ */
async getCategoryValues( async getCategoryValues(
tableName: string, tableName: string,
columnName: string, columnName: string,
menuId: number,
companyCode: string, companyCode: string,
includeInactive: boolean = false includeInactive: boolean = false
): Promise<TableCategoryValue[]> { ): Promise<TableCategoryValue[]> {
@ -111,14 +62,10 @@ class TableCategoryValueService {
logger.info("카테고리 값 목록 조회", { logger.info("카테고리 값 목록 조회", {
tableName, tableName,
columnName, columnName,
menuId,
companyCode, companyCode,
includeInactive, includeInactive,
}); });
// 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함
const siblingMenuIds = await this.getSiblingMenuIds(menuId);
const pool = getPool(); const pool = getPool();
let query = ` let query = `
SELECT SELECT
@ -135,7 +82,6 @@ class TableCategoryValueService {
icon, icon,
is_active AS "isActive", is_active AS "isActive",
is_default AS "isDefault", is_default AS "isDefault",
menu_objid AS "menuId",
company_code AS "companyCode", company_code AS "companyCode",
created_at AS "createdAt", created_at AS "createdAt",
updated_at AS "updatedAt", updated_at AS "updatedAt",
@ -144,16 +90,10 @@ class TableCategoryValueService {
FROM table_column_category_values FROM table_column_category_values
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND menu_objid = ANY($3) AND (company_code = $3 OR company_code = '*')
AND (company_code = $4 OR company_code = '*')
`; `;
const params: any[] = [ const params: any[] = [tableName, columnName, companyCode];
tableName,
columnName,
siblingMenuIds,
companyCode,
];
if (!includeInactive) { if (!includeInactive) {
query += ` AND is_active = true`; query += ` AND is_active = true`;
@ -169,8 +109,6 @@ class TableCategoryValueService {
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, { logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, {
tableName, tableName,
columnName, columnName,
menuId,
siblingMenuIds,
}); });
return values; return values;
@ -216,8 +154,8 @@ class TableCategoryValueService {
INSERT INTO table_column_category_values ( INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon, parent_value_id, depth, description, color, icon,
is_active, is_default, menu_objid, company_code, created_by is_active, is_default, company_code, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING RETURNING
value_id AS "valueId", value_id AS "valueId",
table_name AS "tableName", table_name AS "tableName",
@ -232,7 +170,6 @@ class TableCategoryValueService {
icon, icon,
is_active AS "isActive", is_active AS "isActive",
is_default AS "isDefault", is_default AS "isDefault",
menu_objid AS "menuId",
company_code AS "companyCode", company_code AS "companyCode",
created_at AS "createdAt", created_at AS "createdAt",
created_by AS "createdBy" created_by AS "createdBy"
@ -251,7 +188,6 @@ class TableCategoryValueService {
value.icon || null, value.icon || null,
value.isActive !== false, value.isActive !== false,
value.isDefault || false, value.isDefault || false,
value.menuId, // menuId 추가
companyCode, companyCode,
userId, userId,
]); ]);
@ -343,7 +279,6 @@ class TableCategoryValueService {
icon, icon,
is_active AS "isActive", is_active AS "isActive",
is_default AS "isDefault", is_default AS "isDefault",
menu_objid AS "menuId",
updated_at AS "updatedAt", updated_at AS "updatedAt",
updated_by AS "updatedBy" updated_by AS "updatedBy"
`; `;

View File

@ -26,9 +26,6 @@ export interface TableCategoryValue {
// 하위 항목 (조회 시) // 하위 항목 (조회 시)
children?: TableCategoryValue[]; children?: TableCategoryValue[];
// 메뉴 스코프
menuId: number;
// 멀티테넌시 // 멀티테넌시
companyCode?: string; companyCode?: string;

View File

@ -10,10 +10,10 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -321,20 +321,20 @@ export function CreateTableModal({
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType); const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
return ( return (
<ResizableDialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<ResizableDialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto"> <DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
<ResizableDialogHeader> <DialogHeader>
<ResizableDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"} {isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
</ResizableDialogTitle> </DialogTitle>
<ResizableDialogDescription> <DialogDescription>
{isDuplicateMode {isDuplicateMode
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.` ? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요." : "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
} }
</ResizableDialogDescription> </DialogDescription>
</ResizableDialogHeader> </DialogHeader>
<div className="space-y-6"> <div className="space-y-6">
{/* 테이블 기본 정보 */} {/* 테이블 기본 정보 */}
@ -482,8 +482,8 @@ export function CreateTableModal({
isDuplicateMode ? "복제 생성" : "테이블 생성" isDuplicateMode ? "복제 생성" : "테이블 생성"
)} )}
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</ResizableDialogContent> </DialogContent>
</ResizableDialog> </Dialog>
); );
} }

View File

@ -8,7 +8,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog"; import {
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle
} from "@/components/ui/resizable-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@ -2,13 +2,13 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import {
ResizableDialog, Dialog,
ResizableDialogContent, DialogContent,
ResizableDialogHeader, DialogHeader,
ResizableDialogTitle, DialogTitle,
ResizableDialogDescription, DialogDescription,
ResizableDialogFooter, DialogFooter,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -124,11 +124,11 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
if (!user) return null; if (!user) return null;
return ( return (
<ResizableDialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]"> <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<ResizableDialogHeader> <DialogHeader>
<ResizableDialogTitle className="text-base sm:text-lg"> </ResizableDialogTitle> <DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</ResizableDialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* 사용자 정보 */} {/* 사용자 정보 */}
@ -211,8 +211,8 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
> >
{isLoading ? "처리중..." : showConfirmation ? "확인 및 저장" : "저장"} {isLoading ? "처리중..." : showConfirmation ? "확인 및 저장" : "저장"}
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</ResizableDialogContent> </DialogContent>
</ResizableDialog> </Dialog>
); );
} }

View File

@ -4,11 +4,11 @@ import React, { useState, useEffect } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogTitle,
DialogDescription,
DialogHeader, DialogHeader,
DialogFooter,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -104,13 +104,13 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<ResizableDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" /> <Copy className="h-5 w-5" />
</ResizableDialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{sourceScreen?.screenName} . . {sourceScreen?.screenName} . .
</ResizableDialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
@ -168,7 +168,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
</div> </div>
</div> </div>
<ResizableDialogFooter> <DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCopying}> <Button variant="outline" onClick={handleClose} disabled={isCopying}>
</Button> </Button>
@ -185,7 +185,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
</> </>
)} )}
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -368,18 +368,30 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 검색 가능한 컬럼만 필터링 // 검색 가능한 컬럼만 필터링
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || []; const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
// 컬럼의 실제 웹 타입 정보 찾기 // 컬럼의 실제 웹 타입 정보 찾기 (webType 또는 input_type)
const getColumnWebType = useCallback( const getColumnWebType = useCallback(
(columnName: string) => { (columnName: string) => {
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선) // 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
const componentColumn = component.columns?.find((col) => col.columnName === columnName); const componentColumn = component.columns?.find((col) => col.columnName === columnName);
if (componentColumn?.widgetType && componentColumn.widgetType !== "text") { if (componentColumn?.widgetType && componentColumn.widgetType !== "text") {
console.log(`🔍 [${columnName}] componentColumn.widgetType 사용:`, componentColumn.widgetType);
return componentColumn.widgetType; return componentColumn.widgetType;
} }
// 없으면 테이블 타입 관리에서 설정된 값 찾기 // 없으면 테이블 타입 관리에서 설정된 값 찾기
const tableColumn = tableColumns.find((col) => col.columnName === columnName); const tableColumn = tableColumns.find((col) => col.columnName === columnName);
return tableColumn?.webType || "text";
// input_type 우선 사용 (category 등)
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
if (inputType) {
console.log(`✅ [${columnName}] input_type 사용:`, inputType);
return inputType;
}
// 없으면 webType 사용
const result = tableColumn?.webType || "text";
console.log(`✅ [${columnName}] webType 사용:`, result);
return result;
}, },
[component.columns, tableColumns], [component.columns, tableColumns],
); );
@ -1414,6 +1426,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div> </div>
); );
case "category": {
// 카테고리 셀렉트 (동적 import)
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
console.log("🎯 카테고리 렌더링 (편집 폼):", {
tableName: component.tableName,
columnName: column.columnName,
columnLabel: column.label,
value,
});
return (
<div>
<CategorySelectComponent
tableName={component.tableName}
columnName={column.columnName}
value={value}
onChange={(newValue) => handleEditFormChange(column.columnName, newValue)}
placeholder={advancedConfig?.placeholder || `${column.label} 선택...`}
required={isRequired}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
default: default:
return ( return (
<div> <div>
@ -1676,6 +1713,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div> </div>
); );
case "category": {
// 카테고리 셀렉트 (동적 import)
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
console.log("🎯 카테고리 렌더링 (추가 폼):", {
tableName: component.tableName,
columnName: column.columnName,
columnLabel: column.label,
value,
});
return (
<div>
<CategorySelectComponent
tableName={component.tableName}
columnName={column.columnName}
value={value}
onChange={(newValue) => handleAddFormChange(column.columnName, newValue)}
placeholder={advancedConfig?.placeholder || `${column.label} 선택...`}
required={isRequired}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
default: default:
return ( return (
<div> <div>

View File

@ -4,11 +4,11 @@ import React, { useState, useEffect, useRef } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogTitle,
DialogDescription,
DialogHeader, DialogHeader,
DialogFooter,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -345,26 +345,26 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
return ( return (
<> <>
<ResizableDialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<ResizableDialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
{assignmentSuccess ? ( {assignmentSuccess ? (
// 성공 화면 // 성공 화면
<> <>
<ResizableDialogHeader> <DialogHeader>
<ResizableDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"} {assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"}
</ResizableDialogTitle> </DialogTitle>
<ResizableDialogDescription> <DialogDescription>
{assignmentMessage.includes("나중에") {assignmentMessage.includes("나중에")
? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다." ? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다."
: "화면이 성공적으로 메뉴에 할당되었습니다."} : "화면이 성공적으로 메뉴에 할당되었습니다."}
</ResizableDialogDescription> </DialogDescription>
</ResizableDialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border bg-green-50 p-4"> <div className="rounded-lg border bg-green-50 p-4">
@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
</div> </div>
</div> </div>
<ResizableDialogFooter> <DialogFooter>
<Button <Button
onClick={() => { onClick={() => {
// 타이머 정리 // 타이머 정리
@ -407,19 +407,19 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
<Monitor className="mr-2 h-4 w-4" /> <Monitor className="mr-2 h-4 w-4" />
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</> </>
) : ( ) : (
// 기본 할당 화면 // 기본 할당 화면
<> <>
<ResizableDialogHeader> <DialogHeader>
<ResizableDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />
</ResizableDialogTitle> </DialogTitle>
<ResizableDialogDescription> <DialogDescription>
. .
</ResizableDialogDescription> </DialogDescription>
{screenInfo && ( {screenInfo && (
<div className="bg-accent mt-2 rounded-lg border p-3"> <div className="bg-accent mt-2 rounded-lg border p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -432,7 +432,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
{screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>} {screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>}
</div> </div>
)} )}
</ResizableDialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* 메뉴 선택 (검색 기능 포함) */} {/* 메뉴 선택 (검색 기능 포함) */}
@ -572,22 +572,22 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
</> </>
)} )}
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</> </>
)} )}
</ResizableDialogContent> </DialogContent>
</ResizableDialog> </Dialog>
{/* 화면 교체 확인 대화상자 */} {/* 화면 교체 확인 대화상자 */}
<ResizableDialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}> <Dialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
<ResizableDialogContent className="max-w-md"> <DialogContent className="max-w-md">
<ResizableDialogHeader> <DialogHeader>
<ResizableDialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-orange-600" /> <Monitor className="h-5 w-5 text-orange-600" />
</ResizableDialogTitle> </DialogTitle>
<ResizableDialogDescription> .</ResizableDialogDescription> <DialogDescription> .</DialogDescription>
</ResizableDialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* 기존 화면 목록 */} {/* 기존 화면 목록 */}
@ -652,9 +652,9 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
</> </>
)} )}
</Button> </Button>
</ResizableDialogFooter> </DialogFooter>
</ResizableDialogContent> </DialogContent>
</ResizableDialog> </Dialog>
</> </>
); );
}; };

View File

@ -2555,6 +2555,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentConfig: { componentConfig: {
type: componentId, // text-input, number-input 등 type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존 webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType), ...getDefaultWebTypeConfig(column.widgetType),
// 코드 타입인 경우 코드 카테고리 정보 추가 // 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" && ...(column.widgetType === "code" &&
@ -2618,6 +2619,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentConfig: { componentConfig: {
type: componentId, // text-input, number-input 등 type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존 webType: column.widgetType, // 원본 웹타입 보존
inputType: column.inputType, // ✅ input_type 추가 (category 등)
...getDefaultWebTypeConfig(column.widgetType), ...getDefaultWebTypeConfig(column.widgetType),
// 코드 타입인 경우 코드 카테고리 정보 추가 // 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" && ...(column.widgetType === "code" &&

View File

@ -17,15 +17,24 @@ import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertResizableDialogContent, AlertDialogContent,
AlertResizableDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertResizableDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";

View File

@ -6,49 +6,26 @@ import { CategoryValueManager } from "@/components/table-category/CategoryValueM
interface CategoryWidgetProps { interface CategoryWidgetProps {
widgetId: string; widgetId: string;
menuId?: number; // 현재 화면의 menuId (선택사항)
tableName: string; // 현재 화면의 테이블 tableName: string; // 현재 화면의 테이블
selectedScreen?: any; // 화면 정보 전체 (menuId 추출용)
} }
/** /**
* ( ) * ( )
* - 좌측: 현재 * - 좌측: 현재
* - 우측: 선택된 * - 우측: 선택된 ( )
*/ */
export function CategoryWidget({ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
widgetId,
menuId: propMenuId,
tableName,
selectedScreen,
}: CategoryWidgetProps) {
const [selectedColumn, setSelectedColumn] = useState<{ const [selectedColumn, setSelectedColumn] = useState<{
columnName: string; columnName: string;
columnLabel: string; columnLabel: string;
} | null>(null); } | null>(null);
// menuId 추출: props > selectedScreen > 기본값(1)
const menuId =
propMenuId ||
selectedScreen?.menuId ||
selectedScreen?.menu_id ||
1; // 기본값
// menuId가 없으면 경고 메시지 표시
if (!menuId || menuId === 1) {
console.warn("⚠️ CategoryWidget: menuId가 제공되지 않아 기본값(1)을 사용합니다", {
propMenuId,
selectedScreen,
});
}
return ( return (
<div className="flex h-full min-h-[10px] gap-6"> <div className="flex h-full min-h-[10px] gap-6">
{/* 좌측: 카테고리 컬럼 리스트 (30%) */} {/* 좌측: 카테고리 컬럼 리스트 (30%) */}
<div className="w-[30%] border-r pr-6"> <div className="w-[30%] border-r pr-6">
<CategoryColumnList <CategoryColumnList
tableName={tableName} tableName={tableName}
menuId={menuId}
selectedColumn={selectedColumn?.columnName || null} selectedColumn={selectedColumn?.columnName || null}
onColumnSelect={(columnName, columnLabel) => onColumnSelect={(columnName, columnLabel) =>
setSelectedColumn({ columnName, columnLabel }) setSelectedColumn({ columnName, columnLabel })
@ -63,7 +40,6 @@ export function CategoryWidget({
tableName={tableName} tableName={tableName}
columnName={selectedColumn.columnName} columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel} columnLabel={selectedColumn.columnLabel}
menuId={menuId}
/> />
) : ( ) : (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm"> <div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">

View File

@ -12,18 +12,16 @@ interface CategoryColumn {
interface CategoryColumnListProps { interface CategoryColumnListProps {
tableName: string; tableName: string;
menuId: number;
selectedColumn: string | null; selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string) => void; onColumnSelect: (columnName: string, columnLabel: string) => void;
} }
/** /**
* ( ) * ( )
* - input_type='category' * - input_type='category' ( )
*/ */
export function CategoryColumnList({ export function CategoryColumnList({
tableName, tableName,
menuId,
selectedColumn, selectedColumn,
onColumnSelect, onColumnSelect,
}: CategoryColumnListProps) { }: CategoryColumnListProps) {
@ -32,7 +30,7 @@ export function CategoryColumnList({
useEffect(() => { useEffect(() => {
loadCategoryColumns(); loadCategoryColumns();
}, [tableName, menuId]); }, [tableName]);
const loadCategoryColumns = async () => { const loadCategoryColumns = async () => {
setIsLoading(true); setIsLoading(true);

View File

@ -11,9 +11,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue"; import { TableCategoryValue } from "@/types/tableCategoryValue";
interface CategoryValueAddDialogProps { interface CategoryValueAddDialogProps {
@ -26,33 +24,48 @@ interface CategoryValueAddDialogProps {
export const CategoryValueAddDialog: React.FC< export const CategoryValueAddDialog: React.FC<
CategoryValueAddDialogProps CategoryValueAddDialogProps
> = ({ open, onOpenChange, onAdd, columnLabel }) => { > = ({ open, onOpenChange, onAdd, columnLabel }) => {
const [valueCode, setValueCode] = useState("");
const [valueLabel, setValueLabel] = useState(""); const [valueLabel, setValueLabel] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [color, setColor] = useState("#3b82f6");
const [isDefault, setIsDefault] = useState(false); // 라벨에서 코드 자동 생성
const generateCode = (label: string): string => {
// 한글을 영문으로 변환하거나, 영문/숫자만 추출하여 대문자로
const cleaned = label
.replace(/[^a-zA-Z0-9가-힣\s]/g, "") // 특수문자 제거
.trim()
.toUpperCase();
// 영문이 있으면 영문만, 없으면 타임스탬프 기반
const englishOnly = cleaned.replace(/[^A-Z0-9\s]/g, "").replace(/\s+/g, "_");
if (englishOnly.length > 0) {
return englishOnly.substring(0, 20); // 최대 20자
}
// 영문이 없으면 CATEGORY_TIMESTAMP 형식
return `CATEGORY_${Date.now().toString().slice(-6)}`;
};
const handleSubmit = () => { const handleSubmit = () => {
if (!valueCode || !valueLabel) { if (!valueLabel.trim()) {
return; return;
} }
const valueCode = generateCode(valueLabel);
onAdd({ onAdd({
tableName: "", tableName: "",
columnName: "", columnName: "",
valueCode: valueCode.toUpperCase(), valueCode,
valueLabel, valueLabel: valueLabel.trim(),
description, description: description.trim(),
color, color: "#3b82f6",
isDefault, isDefault: false,
}); });
// 초기화 // 초기화
setValueCode("");
setValueLabel(""); setValueLabel("");
setDescription(""); setDescription("");
setColor("#3b82f6");
setIsDefault(false);
}; };
return ( return (
@ -68,83 +81,23 @@ export const CategoryValueAddDialog: React.FC<
</DialogHeader> </DialogHeader>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
<div> <Input
<Label htmlFor="valueCode" className="text-xs sm:text-sm"> id="valueLabel"
* placeholder="이름 (예: 개발, 긴급, 진행중)"
</Label> value={valueLabel}
<Input onChange={(e) => setValueLabel(e.target.value)}
id="valueCode" className="h-8 text-xs sm:h-10 sm:text-sm"
placeholder="예: DEV, URGENT" autoFocus
value={valueCode} />
onChange={(e) => setValueCode(e.target.value.toUpperCase())}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
(DB )
</p>
</div>
<div> <Textarea
<Label htmlFor="valueLabel" className="text-xs sm:text-sm"> id="description"
* placeholder="설명 (선택사항)"
</Label> value={description}
<Input onChange={(e) => setDescription(e.target.value)}
id="valueLabel" className="text-xs sm:text-sm"
placeholder="예: 개발, 긴급" rows={3}
value={valueLabel} />
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
placeholder="상세 설명 (선택사항)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm"
rows={3}
/>
</div>
<div>
<Label htmlFor="color" className="text-xs sm:text-sm">
</Label>
<div className="flex gap-2">
<Input
id="color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-8 w-16 sm:h-10"
/>
<Input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="isDefault"
checked={isDefault}
onCheckedChange={(checked) => setIsDefault(checked as boolean)}
/>
<Label htmlFor="isDefault" className="text-xs sm:text-sm">
</Label>
</div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
@ -157,7 +110,7 @@ export const CategoryValueAddDialog: React.FC<
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!valueCode || !valueLabel} disabled={!valueLabel.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >

View File

@ -11,9 +11,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue"; import { TableCategoryValue } from "@/types/tableCategoryValue";
interface CategoryValueEditDialogProps { interface CategoryValueEditDialogProps {
@ -29,29 +27,20 @@ export const CategoryValueEditDialog: React.FC<
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => { > = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(value.valueLabel); const [valueLabel, setValueLabel] = useState(value.valueLabel);
const [description, setDescription] = useState(value.description || ""); const [description, setDescription] = useState(value.description || "");
const [color, setColor] = useState(value.color || "#3b82f6");
const [isDefault, setIsDefault] = useState(value.isDefault || false);
const [isActive, setIsActive] = useState(value.isActive !== false);
useEffect(() => { useEffect(() => {
setValueLabel(value.valueLabel); setValueLabel(value.valueLabel);
setDescription(value.description || ""); setDescription(value.description || "");
setColor(value.color || "#3b82f6");
setIsDefault(value.isDefault || false);
setIsActive(value.isActive !== false);
}, [value]); }, [value]);
const handleSubmit = () => { const handleSubmit = () => {
if (!valueLabel) { if (!valueLabel.trim()) {
return; return;
} }
onUpdate(value.valueId!, { onUpdate(value.valueId!, {
valueLabel, valueLabel: valueLabel.trim(),
description, description: description.trim(),
color,
isDefault,
isActive,
}); });
}; };
@ -68,88 +57,23 @@ export const CategoryValueEditDialog: React.FC<
</DialogHeader> </DialogHeader>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
<div> <Input
<Label htmlFor="valueCode" className="text-xs sm:text-sm"> id="valueLabel"
placeholder="이름 (예: 개발, 긴급, 진행중)"
</Label> value={valueLabel}
<Input onChange={(e) => setValueLabel(e.target.value)}
id="valueCode" className="h-8 text-xs sm:h-10 sm:text-sm"
value={value.valueCode} autoFocus
disabled />
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
<div> <Textarea
<Label htmlFor="valueLabel" className="text-xs sm:text-sm"> id="description"
* placeholder="설명 (선택사항)"
</Label> value={description}
<Input onChange={(e) => setDescription(e.target.value)}
id="valueLabel" className="text-xs sm:text-sm"
value={valueLabel} rows={3}
onChange={(e) => setValueLabel(e.target.value)} />
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm"
rows={3}
/>
</div>
<div>
<Label htmlFor="color" className="text-xs sm:text-sm">
</Label>
<div className="flex gap-2">
<Input
id="color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-8 w-16 sm:h-10"
/>
<Input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="isDefault"
checked={isDefault}
onCheckedChange={(checked) => setIsDefault(checked as boolean)}
/>
<Label htmlFor="isDefault" className="text-xs sm:text-sm">
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={isActive}
onCheckedChange={(checked) => setIsActive(checked as boolean)}
/>
<Label htmlFor="isActive" className="text-xs sm:text-sm">
</Label>
</div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
@ -162,7 +86,7 @@ export const CategoryValueEditDialog: React.FC<
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!valueLabel} disabled={!valueLabel.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >

View File

@ -5,13 +5,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { import {
Plus, Plus,
Search, Search,
Trash2, Trash2,
Edit2, Edit2,
CheckCircle2,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { import {
getCategoryValues, getCategoryValues,
@ -29,7 +28,6 @@ interface CategoryValueManagerProps {
tableName: string; tableName: string;
columnName: string; columnName: string;
columnLabel: string; columnLabel: string;
menuId: number; // 메뉴 스코프
onValueCountChange?: (count: number) => void; onValueCountChange?: (count: number) => void;
} }
@ -37,7 +35,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
tableName, tableName,
columnName, columnName,
columnLabel, columnLabel,
menuId,
onValueCountChange, onValueCountChange,
}) => { }) => {
const { toast } = useToast(); const { toast } = useToast();
@ -56,7 +53,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
// 카테고리 값 로드 // 카테고리 값 로드
useEffect(() => { useEffect(() => {
loadCategoryValues(); loadCategoryValues();
}, [tableName, columnName, menuId]); }, [tableName, columnName]);
// 검색 필터링 // 검색 필터링
useEffect(() => { useEffect(() => {
@ -75,7 +72,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
const loadCategoryValues = async () => { const loadCategoryValues = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await getCategoryValues(tableName, columnName, menuId); const response = await getCategoryValues(tableName, columnName);
if (response.success && response.data) { if (response.success && response.data) {
setValues(response.data); setValues(response.data);
setFilteredValues(response.data); setFilteredValues(response.data);
@ -99,7 +96,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
...newValue, ...newValue,
tableName, tableName,
columnName, columnName,
menuId,
}); });
if (response.success && response.data) { if (response.success && response.data) {
@ -109,11 +105,19 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
title: "성공", title: "성공",
description: "카테고리 값이 추가되었습니다", description: "카테고리 값이 추가되었습니다",
}); });
} else {
console.error("❌ 카테고리 값 추가 실패:", response);
toast({
title: "오류",
description: response.error || "카테고리 값 추가에 실패했습니다",
variant: "destructive",
});
} }
} catch (error) { } catch (error: any) {
console.error("❌ 카테고리 값 추가 예외:", error);
toast({ toast({
title: "오류", title: "오류",
description: "카테고리 값 추가에 실패했습니다", description: error.message || "카테고리 값 추가에 실패했습니다",
variant: "destructive", variant: "destructive",
}); });
} }
@ -219,6 +223,35 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
); );
}; };
const handleToggleActive = async (valueId: number, currentIsActive: boolean) => {
try {
const response = await updateCategoryValue(valueId, {
isActive: !currentIsActive,
});
if (response.success) {
await loadCategoryValues();
toast({
title: "성공",
description: `카테고리 값이 ${!currentIsActive ? "활성화" : "비활성화"}되었습니다`,
});
} else {
toast({
title: "오류",
description: response.error || "상태 변경에 실패했습니다",
variant: "destructive",
});
}
} catch (error: any) {
console.error("❌ 활성 상태 변경 실패:", error);
toast({
title: "오류",
description: error.message || "상태 변경에 실패했습니다",
variant: "destructive",
});
}
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* 헤더 */} {/* 헤더 */}
@ -270,39 +303,42 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
onCheckedChange={() => handleSelectValue(value.valueId!)} onCheckedChange={() => handleSelectValue(value.valueId!)}
/> />
<div className="flex-1"> <div className="flex flex-1 items-center gap-2">
<div className="flex items-center gap-2"> <Badge variant="outline" className="text-xs">
<Badge variant="outline" className="text-xs"> {value.valueCode}
{value.valueCode} </Badge>
</Badge> <span className="text-sm font-medium">
<span className="text-sm font-medium"> {value.valueLabel}
{value.valueLabel} </span>
</span>
{value.isDefault && (
<Badge variant="secondary" className="text-[10px]">
</Badge>
)}
{value.color && (
<div
className="h-4 w-4 rounded-full border"
style={{ backgroundColor: value.color }}
/>
)}
</div>
{value.description && ( {value.description && (
<p className="mt-1 text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{value.description} - {value.description}
</p> </span>
)}
{value.isDefault && (
<Badge variant="secondary" className="text-[10px]">
</Badge>
)}
{value.color && (
<div
className="h-4 w-4 rounded-full border"
style={{ backgroundColor: value.color }}
/>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{value.isActive ? ( <Switch
<CheckCircle2 className="h-4 w-4 text-success" /> checked={value.isActive !== false}
) : ( onCheckedChange={() =>
<XCircle className="h-4 w-4 text-destructive" /> handleToggleActive(
)} value.valueId!,
value.isActive !== false
)
}
className="data-[state=checked]:bg-emerald-500"
/>
<Button <Button
variant="ghost" variant="ghost"

View File

@ -21,12 +21,11 @@ export async function getCategoryColumns(tableName: string) {
} }
/** /**
* ( ) * ( )
*/ */
export async function getCategoryValues( export async function getCategoryValues(
tableName: string, tableName: string,
columnName: string, columnName: string,
menuId: number,
includeInactive: boolean = false includeInactive: boolean = false
) { ) {
try { try {
@ -34,7 +33,7 @@ export async function getCategoryValues(
success: boolean; success: boolean;
data: TableCategoryValue[]; data: TableCategoryValue[];
}>(`/table-categories/${tableName}/${columnName}/values`, { }>(`/table-categories/${tableName}/${columnName}/values`, {
params: { menuId, includeInactive }, params: { includeInactive },
}); });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {

View File

@ -142,6 +142,53 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
const componentType = (component as any).componentType || component.type; const componentType = (component as any).componentType || component.type;
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
const inputType = (component as any).componentConfig?.inputType || (component as any).inputType;
const webType = (component as any).componentConfig?.webType;
const tableName = (component as any).tableName;
const columnName = (component as any).columnName;
console.log("🔍 DynamicComponentRenderer 컴포넌트 타입 확인:", {
componentId: component.id,
componentType,
inputType,
webType,
tableName,
columnName,
componentConfig: (component as any).componentConfig,
});
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
if ((inputType === "category" || webType === "category") && tableName && columnName) {
console.log("✅ 카테고리 타입 감지 → CategorySelectComponent 렌더링");
try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const fieldName = columnName || component.id;
const currentValue = props.formData?.[fieldName] || "";
const handleChange = (value: any) => {
if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value);
}
};
return (
<CategorySelectComponent
tableName={tableName}
columnName={columnName}
value={currentValue}
onChange={handleChange}
placeholder={component.componentConfig?.placeholder || "선택하세요"}
required={(component as any).required}
disabled={(component as any).readonly}
className="w-full"
/>
);
} catch (error) {
console.error("❌ CategorySelectComponent 로드 실패:", error);
}
}
// 레이아웃 컴포넌트 처리 // 레이아웃 컴포넌트 처리
if (componentType === "layout") { if (componentType === "layout") {
// DOM 안전한 props만 전달 // DOM 안전한 props만 전달

View File

@ -163,6 +163,13 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
return <DateInputComponent {...props} {...finalProps} />; return <DateInputComponent {...props} {...finalProps} />;
} }
// 카테고리 셀렉트 웹타입
if (webType === "category") {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
console.log(`✅ 폴백: ${webType} 웹타입 → CategorySelectComponent 사용`);
return <CategorySelectComponent {...props} {...finalProps} />;
}
// 기본 폴백: Input 컴포넌트 사용 // 기본 폴백: Input 컴포넌트 사용
const { Input } = require("@/components/ui/input"); const { Input } = require("@/components/ui/input");
const { filterDOMProps } = require("@/lib/utils/domPropsFilter"); const { filterDOMProps } = require("@/lib/utils/domPropsFilter");

View File

@ -0,0 +1,161 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { TableCategoryValue } from "@/types/tableCategoryValue";
import { Loader2 } from "lucide-react";
interface CategorySelectComponentProps {
component?: any;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
readonly?: boolean;
tableName?: string;
columnName?: string;
}
/**
*
* -
* -
*/
export const CategorySelectComponent: React.FC<
CategorySelectComponentProps
> = ({
component,
value = "",
onChange,
placeholder = "선택하세요",
disabled = false,
required = false,
className = "",
readonly = false,
tableName: propTableName,
columnName: propColumnName,
}) => {
const [categoryValues, setCategoryValues] = useState<TableCategoryValue[]>(
[]
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 테이블명과 컬럼명 추출
const tableName = propTableName || component?.tableName;
const columnName = propColumnName || component?.columnName;
useEffect(() => {
if (!tableName || !columnName) {
console.warn("CategorySelectComponent: tableName 또는 columnName이 없습니다", {
tableName,
columnName,
component,
});
return;
}
loadCategoryValues();
}, [tableName, columnName]);
const loadCategoryValues = async () => {
if (!tableName || !columnName) return;
setIsLoading(true);
setError(null);
try {
console.log("📦 카테고리 값 조회:", { tableName, columnName });
const response = await getCategoryValues(tableName, columnName);
if (response.success && response.data) {
// 활성화된 값만 필터링
const activeValues = response.data.filter((v) => v.isActive !== false);
setCategoryValues(activeValues);
console.log("✅ 카테고리 값 조회 성공:", {
total: response.data.length,
active: activeValues.length,
values: activeValues,
});
} else {
setError("카테고리 값을 불러올 수 없습니다");
console.error("❌ 카테고리 값 조회 실패:", response);
}
} catch (err: any) {
setError("카테고리 값 조회 중 오류가 발생했습니다");
console.error("❌ 카테고리 값 조회 예외:", err);
} finally {
setIsLoading(false);
}
};
const handleValueChange = (newValue: string) => {
console.log("🔄 카테고리 값 변경:", { oldValue: value, newValue });
onChange?.(newValue);
};
// 로딩 중
if (isLoading) {
return (
<div className="flex h-10 w-full items-center justify-center rounded-md border bg-background px-3 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</div>
);
}
// 에러
if (error) {
return (
<div className="flex h-10 w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 text-sm text-destructive">
{error}
</div>
);
}
// 카테고리 값이 없음
if (categoryValues.length === 0) {
return (
<div className="flex h-10 w-full items-center rounded-md border bg-muted px-3 text-sm text-muted-foreground">
</div>
);
}
return (
<Select
value={value}
onValueChange={handleValueChange}
disabled={disabled || readonly}
required={required}
>
<SelectTrigger className={`w-full ${className}`}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{categoryValues.map((categoryValue) => (
<SelectItem
key={categoryValue.valueId}
value={categoryValue.valueCode}
>
{categoryValue.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
CategorySelectComponent.displayName = "CategorySelectComponent";

View File

@ -64,7 +64,7 @@ export const isWidgetComponent = (component: ComponentData): boolean => {
}; };
/** /**
* * (input_type )
*/ */
export const getComponentWebType = (component: ComponentData): string | undefined => { export const getComponentWebType = (component: ComponentData): string | undefined => {
if (!component || !component.type) return undefined; if (!component || !component.type) return undefined;
@ -80,13 +80,49 @@ export const getComponentWebType = (component: ComponentData): string | undefine
return "file"; return "file";
} }
if (component.type === "widget") { // 1. componentConfig.inputType 우선 확인 (새로 추가된 컴포넌트)
return (component as any).widgetType; const configInputType = (component as any).componentConfig?.inputType;
if (configInputType) {
console.log(`✅ 컴포넌트 componentConfig.inputType 사용:`, {
componentId: component.id,
tableName: (component as any).tableName,
columnName: (component as any).columnName,
inputType: configInputType,
componentConfig: (component as any).componentConfig,
});
return configInputType;
} }
if (component.type === "component") {
return (component as any).widgetType || (component as any).componentConfig?.webType; // 2. 루트 레벨 input_type 확인 (하위 호환성)
const rootInputType = (component as any).input_type || (component as any).inputType;
if (rootInputType) {
console.log(`✅ 컴포넌트 루트 inputType 사용:`, {
componentId: component.id,
tableName: (component as any).tableName,
columnName: (component as any).columnName,
inputType: rootInputType,
});
return rootInputType;
} }
return component.type;
// 3. 기본 웹타입 확인
const webType = component.type === "widget"
? (component as any).widgetType
: component.type === "component"
? (component as any).widgetType || (component as any).componentConfig?.webType
: component.type;
console.log(`⚠️ inputType 없음, 기본 webType 사용:`, {
componentId: component.id,
tableName: (component as any).tableName,
columnName: (component as any).columnName,
type: component.type,
widgetType: (component as any).widgetType,
componentConfig: (component as any).componentConfig,
resultWebType: webType,
});
return webType;
}; };
/** /**

View File

@ -49,6 +49,7 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
label: "text-display", label: "text-display",
code: "select-basic", // 코드 타입은 선택상자 사용 code: "select-basic", // 코드 타입은 선택상자 사용
entity: "select-basic", // 엔티티 타입은 선택상자 사용 entity: "select-basic", // 엔티티 타입은 선택상자 사용
category: "select-basic", // 카테고리 타입은 선택상자 사용
}; };
/** /**

View File

@ -26,9 +26,6 @@ export interface TableCategoryValue {
// 하위 항목 // 하위 항목
children?: TableCategoryValue[]; children?: TableCategoryValue[];
// 메뉴 스코프
menuId: number;
// 멀티테넌시 // 멀티테넌시
companyCode?: string; companyCode?: string;