카테고리 구현

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 {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
const menuId = parseInt(req.query.menuId as string, 10);
const includeInactive = req.query.includeInactive === "true";
if (!menuId || isNaN(menuId)) {
return res.status(400).json({
success: false,
message: "menuId 파라미터가 필요합니다",
});
}
const values = await tableCategoryValueService.getCategoryValues(
tableName,
columnName,
menuId,
companyCode,
includeInactive
);

View File

@ -6,54 +6,6 @@ import {
} from "../types/tableCategoryValue";
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(
tableName: string,
columnName: string,
menuId: number,
companyCode: string,
includeInactive: boolean = false
): Promise<TableCategoryValue[]> {
@ -111,14 +62,10 @@ class TableCategoryValueService {
logger.info("카테고리 값 목록 조회", {
tableName,
columnName,
menuId,
companyCode,
includeInactive,
});
// 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함
const siblingMenuIds = await this.getSiblingMenuIds(menuId);
const pool = getPool();
let query = `
SELECT
@ -135,7 +82,6 @@ class TableCategoryValueService {
icon,
is_active AS "isActive",
is_default AS "isDefault",
menu_objid AS "menuId",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt",
@ -144,16 +90,10 @@ class TableCategoryValueService {
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_objid = ANY($3)
AND (company_code = $4 OR company_code = '*')
AND (company_code = $3 OR company_code = '*')
`;
const params: any[] = [
tableName,
columnName,
siblingMenuIds,
companyCode,
];
const params: any[] = [tableName, columnName, companyCode];
if (!includeInactive) {
query += ` AND is_active = true`;
@ -169,8 +109,6 @@ class TableCategoryValueService {
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, {
tableName,
columnName,
menuId,
siblingMenuIds,
});
return values;
@ -216,8 +154,8 @@ class TableCategoryValueService {
INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon,
is_active, is_default, menu_objid, company_code, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
is_active, is_default, company_code, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING
value_id AS "valueId",
table_name AS "tableName",
@ -232,7 +170,6 @@ class TableCategoryValueService {
icon,
is_active AS "isActive",
is_default AS "isDefault",
menu_objid AS "menuId",
company_code AS "companyCode",
created_at AS "createdAt",
created_by AS "createdBy"
@ -251,7 +188,6 @@ class TableCategoryValueService {
value.icon || null,
value.isActive !== false,
value.isDefault || false,
value.menuId, // menuId 추가
companyCode,
userId,
]);
@ -343,7 +279,6 @@ class TableCategoryValueService {
icon,
is_active AS "isActive",
is_default AS "isDefault",
menu_objid AS "menuId",
updated_at AS "updatedAt",
updated_by AS "updatedBy"
`;

View File

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

View File

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

View File

@ -8,7 +8,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { toast } from "sonner";

View File

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

View File

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

View File

@ -368,18 +368,30 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 검색 가능한 컬럼만 필터링
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
// 컬럼의 실제 웹 타입 정보 찾기
// 컬럼의 실제 웹 타입 정보 찾기 (webType 또는 input_type)
const getColumnWebType = useCallback(
(columnName: string) => {
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
if (componentColumn?.widgetType && componentColumn.widgetType !== "text") {
console.log(`🔍 [${columnName}] componentColumn.widgetType 사용:`, componentColumn.widgetType);
return componentColumn.widgetType;
}
// 없으면 테이블 타입 관리에서 설정된 값 찾기
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],
);
@ -1414,6 +1426,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</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:
return (
<div>
@ -1676,6 +1713,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</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:
return (
<div>

View File

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

View File

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

View File

@ -17,15 +17,24 @@ import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertResizableDialogContent,
AlertResizableDialogDescription,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertResizableDialogHeader,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Textarea } from "@/components/ui/textarea";
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 { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";

View File

@ -6,49 +6,26 @@ import { CategoryValueManager } from "@/components/table-category/CategoryValueM
interface CategoryWidgetProps {
widgetId: string;
menuId?: number; // 현재 화면의 menuId (선택사항)
tableName: string; // 현재 화면의 테이블
selectedScreen?: any; // 화면 정보 전체 (menuId 추출용)
}
/**
* ( )
* - 좌측: 현재
* - 우측: 선택된
* - 우측: 선택된 ( )
*/
export function CategoryWidget({
widgetId,
menuId: propMenuId,
tableName,
selectedScreen,
}: CategoryWidgetProps) {
export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
const [selectedColumn, setSelectedColumn] = useState<{
columnName: string;
columnLabel: string;
} | 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 (
<div className="flex h-full min-h-[10px] gap-6">
{/* 좌측: 카테고리 컬럼 리스트 (30%) */}
<div className="w-[30%] border-r pr-6">
<CategoryColumnList
tableName={tableName}
menuId={menuId}
selectedColumn={selectedColumn?.columnName || null}
onColumnSelect={(columnName, columnLabel) =>
setSelectedColumn({ columnName, columnLabel })
@ -63,7 +40,6 @@ export function CategoryWidget({
tableName={tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuId={menuId}
/>
) : (
<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 {
tableName: string;
menuId: number;
selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string) => void;
}
/**
* ( )
* - input_type='category'
* - input_type='category' ( )
*/
export function CategoryColumnList({
tableName,
menuId,
selectedColumn,
onColumnSelect,
}: CategoryColumnListProps) {
@ -32,7 +30,7 @@ export function CategoryColumnList({
useEffect(() => {
loadCategoryColumns();
}, [tableName, menuId]);
}, [tableName]);
const loadCategoryColumns = async () => {
setIsLoading(true);

View File

@ -11,9 +11,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue";
interface CategoryValueAddDialogProps {
@ -26,33 +24,48 @@ interface CategoryValueAddDialogProps {
export const CategoryValueAddDialog: React.FC<
CategoryValueAddDialogProps
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
const [valueCode, setValueCode] = useState("");
const [valueLabel, setValueLabel] = 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 = () => {
if (!valueCode || !valueLabel) {
if (!valueLabel.trim()) {
return;
}
const valueCode = generateCode(valueLabel);
onAdd({
tableName: "",
columnName: "",
valueCode: valueCode.toUpperCase(),
valueLabel,
description,
color,
isDefault,
valueCode,
valueLabel: valueLabel.trim(),
description: description.trim(),
color: "#3b82f6",
isDefault: false,
});
// 초기화
setValueCode("");
setValueLabel("");
setDescription("");
setColor("#3b82f6");
setIsDefault(false);
};
return (
@ -68,83 +81,23 @@ export const CategoryValueAddDialog: React.FC<
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueCode" className="text-xs sm:text-sm">
*
</Label>
<Input
id="valueCode"
placeholder="예: DEV, URGENT"
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>
<Input
id="valueLabel"
placeholder="이름 (예: 개발, 긴급, 진행중)"
value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
autoFocus
/>
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
*
</Label>
<Input
id="valueLabel"
placeholder="예: 개발, 긴급"
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>
<Textarea
id="description"
placeholder="설명 (선택사항)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm"
rows={3}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
@ -157,7 +110,7 @@ export const CategoryValueAddDialog: React.FC<
</Button>
<Button
onClick={handleSubmit}
disabled={!valueCode || !valueLabel}
disabled={!valueLabel.trim()}
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";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue";
interface CategoryValueEditDialogProps {
@ -29,29 +27,20 @@ export const CategoryValueEditDialog: React.FC<
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(value.valueLabel);
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(() => {
setValueLabel(value.valueLabel);
setDescription(value.description || "");
setColor(value.color || "#3b82f6");
setIsDefault(value.isDefault || false);
setIsActive(value.isActive !== false);
}, [value]);
const handleSubmit = () => {
if (!valueLabel) {
if (!valueLabel.trim()) {
return;
}
onUpdate(value.valueId!, {
valueLabel,
description,
color,
isDefault,
isActive,
valueLabel: valueLabel.trim(),
description: description.trim(),
});
};
@ -68,88 +57,23 @@ export const CategoryValueEditDialog: React.FC<
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueCode" className="text-xs sm:text-sm">
</Label>
<Input
id="valueCode"
value={value.valueCode}
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>
<Input
id="valueLabel"
placeholder="이름 (예: 개발, 긴급, 진행중)"
value={valueLabel}
onChange={(e) => setValueLabel(e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
autoFocus
/>
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
*
</Label>
<Input
id="valueLabel"
value={valueLabel}
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>
<Textarea
id="description"
placeholder="설명 (선택사항)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-xs sm:text-sm"
rows={3}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
@ -162,7 +86,7 @@ export const CategoryValueEditDialog: React.FC<
</Button>
<Button
onClick={handleSubmit}
disabled={!valueLabel}
disabled={!valueLabel.trim()}
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 { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Plus,
Search,
Trash2,
Edit2,
CheckCircle2,
XCircle,
} from "lucide-react";
import {
getCategoryValues,
@ -29,7 +28,6 @@ interface CategoryValueManagerProps {
tableName: string;
columnName: string;
columnLabel: string;
menuId: number; // 메뉴 스코프
onValueCountChange?: (count: number) => void;
}
@ -37,7 +35,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
tableName,
columnName,
columnLabel,
menuId,
onValueCountChange,
}) => {
const { toast } = useToast();
@ -56,7 +53,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
// 카테고리 값 로드
useEffect(() => {
loadCategoryValues();
}, [tableName, columnName, menuId]);
}, [tableName, columnName]);
// 검색 필터링
useEffect(() => {
@ -75,7 +72,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
const loadCategoryValues = async () => {
setIsLoading(true);
try {
const response = await getCategoryValues(tableName, columnName, menuId);
const response = await getCategoryValues(tableName, columnName);
if (response.success && response.data) {
setValues(response.data);
setFilteredValues(response.data);
@ -99,7 +96,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
...newValue,
tableName,
columnName,
menuId,
});
if (response.success && response.data) {
@ -109,11 +105,19 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
title: "성공",
description: "카테고리 값이 추가되었습니다",
});
} else {
console.error("❌ 카테고리 값 추가 실패:", response);
toast({
title: "오류",
description: response.error || "카테고리 값 추가에 실패했습니다",
variant: "destructive",
});
}
} catch (error) {
} catch (error: any) {
console.error("❌ 카테고리 값 추가 예외:", error);
toast({
title: "오류",
description: "카테고리 값 추가에 실패했습니다",
description: error.message || "카테고리 값 추가에 실패했습니다",
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 (
<div className="flex h-full flex-col">
{/* 헤더 */}
@ -270,39 +303,42 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
onCheckedChange={() => handleSelectValue(value.valueId!)}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{value.valueCode}
</Badge>
<span className="text-sm font-medium">
{value.valueLabel}
</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 className="flex flex-1 items-center gap-2">
<Badge variant="outline" className="text-xs">
{value.valueCode}
</Badge>
<span className="text-sm font-medium">
{value.valueLabel}
</span>
{value.description && (
<p className="mt-1 text-xs text-muted-foreground">
{value.description}
</p>
<span className="text-xs text-muted-foreground">
- {value.description}
</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 className="flex items-center gap-2">
{value.isActive ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<XCircle className="h-4 w-4 text-destructive" />
)}
<Switch
checked={value.isActive !== false}
onCheckedChange={() =>
handleToggleActive(
value.valueId!,
value.isActive !== false
)
}
className="data-[state=checked]:bg-emerald-500"
/>
<Button
variant="ghost"

View File

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

View File

@ -142,6 +142,53 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 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") {
// DOM 안전한 props만 전달

View File

@ -163,6 +163,13 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
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 컴포넌트 사용
const { Input } = require("@/components/ui/input");
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 => {
if (!component || !component.type) return undefined;
@ -80,13 +80,49 @@ export const getComponentWebType = (component: ComponentData): string | undefine
return "file";
}
if (component.type === "widget") {
return (component as any).widgetType;
// 1. componentConfig.inputType 우선 확인 (새로 추가된 컴포넌트)
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",
code: "select-basic", // 코드 타입은 선택상자 사용
entity: "select-basic", // 엔티티 타입은 선택상자 사용
category: "select-basic", // 카테고리 타입은 선택상자 사용
};
/**

View File

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