카테고리 구현
This commit is contained in:
parent
f3bed0d713
commit
bc029d1df8
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -26,9 +26,6 @@ export interface TableCategoryValue {
|
|||
// 하위 항목 (조회 시)
|
||||
children?: TableCategoryValue[];
|
||||
|
||||
// 메뉴 스코프
|
||||
menuId: number;
|
||||
|
||||
// 멀티테넌시
|
||||
companyCode?: string;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
추가
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
저장
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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만 전달
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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", // 카테고리 타입은 선택상자 사용
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,9 +26,6 @@ export interface TableCategoryValue {
|
|||
// 하위 항목
|
||||
children?: TableCategoryValue[];
|
||||
|
||||
// 메뉴 스코프
|
||||
menuId: number;
|
||||
|
||||
// 멀티테넌시
|
||||
companyCode?: string;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue