카테고리 구현
This commit is contained in:
parent
f3bed0d713
commit
bc029d1df8
|
|
@ -36,20 +36,11 @@ export const getCategoryValues = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
const menuId = parseInt(req.query.menuId as string, 10);
|
|
||||||
const includeInactive = req.query.includeInactive === "true";
|
const includeInactive = req.query.includeInactive === "true";
|
||||||
|
|
||||||
if (!menuId || isNaN(menuId)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "menuId 파라미터가 필요합니다",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = await tableCategoryValueService.getCategoryValues(
|
const values = await tableCategoryValueService.getCategoryValues(
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
menuId,
|
|
||||||
companyCode,
|
companyCode,
|
||||||
includeInactive
|
includeInactive
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,54 +6,6 @@ import {
|
||||||
} from "../types/tableCategoryValue";
|
} from "../types/tableCategoryValue";
|
||||||
|
|
||||||
class TableCategoryValueService {
|
class TableCategoryValueService {
|
||||||
/**
|
|
||||||
* 메뉴의 형제 메뉴 ID 목록 조회
|
|
||||||
* (같은 부모를 가진 메뉴들)
|
|
||||||
*/
|
|
||||||
async getSiblingMenuIds(menuId: number): Promise<number[]> {
|
|
||||||
try {
|
|
||||||
const pool = getPool();
|
|
||||||
|
|
||||||
// 1. 현재 메뉴의 부모 ID 조회 (menu_info는 objid와 parent_obj_id 사용)
|
|
||||||
const parentQuery = `
|
|
||||||
SELECT parent_obj_id FROM menu_info WHERE objid = $1
|
|
||||||
`;
|
|
||||||
const parentResult = await pool.query(parentQuery, [menuId]);
|
|
||||||
|
|
||||||
if (parentResult.rows.length === 0) {
|
|
||||||
logger.warn(`메뉴 ID ${menuId}를 찾을 수 없습니다`);
|
|
||||||
return [menuId];
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentId = parentResult.rows[0].parent_obj_id;
|
|
||||||
|
|
||||||
// 최상위 메뉴인 경우 (parent_obj_id가 null 또는 0)
|
|
||||||
if (!parentId || parentId === 0) {
|
|
||||||
logger.info(`메뉴 ${menuId}는 최상위 메뉴입니다`);
|
|
||||||
return [menuId];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 같은 부모를 가진 형제 메뉴들 조회
|
|
||||||
const siblingsQuery = `
|
|
||||||
SELECT objid FROM menu_info WHERE parent_obj_id = $1
|
|
||||||
`;
|
|
||||||
const siblingsResult = await pool.query(siblingsQuery, [parentId]);
|
|
||||||
|
|
||||||
const siblingIds = siblingsResult.rows.map((row) => Number(row.objid));
|
|
||||||
|
|
||||||
logger.info(`메뉴 ${menuId}의 형제 메뉴 ${siblingIds.length}개 조회`, {
|
|
||||||
menuId,
|
|
||||||
parentId,
|
|
||||||
siblings: siblingIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
return siblingIds;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`형제 메뉴 조회 실패: ${error.message}`);
|
|
||||||
// 에러 시 현재 메뉴만 반환
|
|
||||||
return [menuId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* 테이블의 카테고리 타입 컬럼 목록 조회
|
* 테이블의 카테고리 타입 컬럼 목록 조회
|
||||||
*/
|
*/
|
||||||
|
|
@ -98,12 +50,11 @@ class TableCategoryValueService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
* 특정 컬럼의 카테고리 값 목록 조회 (테이블 스코프)
|
||||||
*/
|
*/
|
||||||
async getCategoryValues(
|
async getCategoryValues(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
menuId: number,
|
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
includeInactive: boolean = false
|
includeInactive: boolean = false
|
||||||
): Promise<TableCategoryValue[]> {
|
): Promise<TableCategoryValue[]> {
|
||||||
|
|
@ -111,14 +62,10 @@ class TableCategoryValueService {
|
||||||
logger.info("카테고리 값 목록 조회", {
|
logger.info("카테고리 값 목록 조회", {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
menuId,
|
|
||||||
companyCode,
|
companyCode,
|
||||||
includeInactive,
|
includeInactive,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함
|
|
||||||
const siblingMenuIds = await this.getSiblingMenuIds(menuId);
|
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -135,7 +82,6 @@ class TableCategoryValueService {
|
||||||
icon,
|
icon,
|
||||||
is_active AS "isActive",
|
is_active AS "isActive",
|
||||||
is_default AS "isDefault",
|
is_default AS "isDefault",
|
||||||
menu_objid AS "menuId",
|
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
|
|
@ -144,16 +90,10 @@ class TableCategoryValueService {
|
||||||
FROM table_column_category_values
|
FROM table_column_category_values
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
AND menu_objid = ANY($3)
|
AND (company_code = $3 OR company_code = '*')
|
||||||
AND (company_code = $4 OR company_code = '*')
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: any[] = [
|
const params: any[] = [tableName, columnName, companyCode];
|
||||||
tableName,
|
|
||||||
columnName,
|
|
||||||
siblingMenuIds,
|
|
||||||
companyCode,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!includeInactive) {
|
if (!includeInactive) {
|
||||||
query += ` AND is_active = true`;
|
query += ` AND is_active = true`;
|
||||||
|
|
@ -169,8 +109,6 @@ class TableCategoryValueService {
|
||||||
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, {
|
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
menuId,
|
|
||||||
siblingMenuIds,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return values;
|
return values;
|
||||||
|
|
@ -216,8 +154,8 @@ class TableCategoryValueService {
|
||||||
INSERT INTO table_column_category_values (
|
INSERT INTO table_column_category_values (
|
||||||
table_name, column_name, value_code, value_label, value_order,
|
table_name, column_name, value_code, value_label, value_order,
|
||||||
parent_value_id, depth, description, color, icon,
|
parent_value_id, depth, description, color, icon,
|
||||||
is_active, is_default, menu_objid, company_code, created_by
|
is_active, is_default, company_code, created_by
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
RETURNING
|
RETURNING
|
||||||
value_id AS "valueId",
|
value_id AS "valueId",
|
||||||
table_name AS "tableName",
|
table_name AS "tableName",
|
||||||
|
|
@ -232,7 +170,6 @@ class TableCategoryValueService {
|
||||||
icon,
|
icon,
|
||||||
is_active AS "isActive",
|
is_active AS "isActive",
|
||||||
is_default AS "isDefault",
|
is_default AS "isDefault",
|
||||||
menu_objid AS "menuId",
|
|
||||||
company_code AS "companyCode",
|
company_code AS "companyCode",
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
created_by AS "createdBy"
|
created_by AS "createdBy"
|
||||||
|
|
@ -251,7 +188,6 @@ class TableCategoryValueService {
|
||||||
value.icon || null,
|
value.icon || null,
|
||||||
value.isActive !== false,
|
value.isActive !== false,
|
||||||
value.isDefault || false,
|
value.isDefault || false,
|
||||||
value.menuId, // menuId 추가
|
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
|
|
@ -343,7 +279,6 @@ class TableCategoryValueService {
|
||||||
icon,
|
icon,
|
||||||
is_active AS "isActive",
|
is_active AS "isActive",
|
||||||
is_default AS "isDefault",
|
is_default AS "isDefault",
|
||||||
menu_objid AS "menuId",
|
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
updated_by AS "updatedBy"
|
updated_by AS "updatedBy"
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,6 @@ export interface TableCategoryValue {
|
||||||
// 하위 항목 (조회 시)
|
// 하위 항목 (조회 시)
|
||||||
children?: TableCategoryValue[];
|
children?: TableCategoryValue[];
|
||||||
|
|
||||||
// 메뉴 스코프
|
|
||||||
menuId: number;
|
|
||||||
|
|
||||||
// 멀티테넌시
|
// 멀티테넌시
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -321,20 +321,20 @@ export function CreateTableModal({
|
||||||
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
|
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<ResizableDialogDescription>
|
<DialogDescription>
|
||||||
{isDuplicateMode
|
{isDuplicateMode
|
||||||
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
||||||
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
||||||
}
|
}
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 테이블 기본 정보 */}
|
{/* 테이블 기본 정보 */}
|
||||||
|
|
@ -482,8 +482,8 @@ export function CreateTableModal({
|
||||||
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
import {
|
||||||
|
ResizableDialog,
|
||||||
|
ResizableDialogContent,
|
||||||
|
ResizableDialogHeader,
|
||||||
|
ResizableDialogTitle
|
||||||
|
} from "@/components/ui/resizable-dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -124,11 +124,11 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">사용자 권한 변경</ResizableDialogTitle>
|
<DialogTitle className="text-base sm:text-lg">사용자 권한 변경</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 사용자 정보 */}
|
{/* 사용자 정보 */}
|
||||||
|
|
@ -211,8 +211,8 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
|
||||||
>
|
>
|
||||||
{isLoading ? "처리중..." : showConfirmation ? "확인 및 저장" : "저장"}
|
{isLoading ? "처리중..." : showConfirmation ? "확인 및 저장" : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -104,13 +104,13 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Copy className="h-5 w-5" />
|
<Copy className="h-5 w-5" />
|
||||||
화면 복사
|
화면 복사
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{sourceScreen?.screenName} 화면을 복사합니다. 화면 구성도 함께 복사됩니다.
|
{sourceScreen?.screenName} 화면을 복사합니다. 화면 구성도 함께 복사됩니다.
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -168,7 +168,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
|
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -185,7 +185,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -368,18 +368,30 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 검색 가능한 컬럼만 필터링
|
// 검색 가능한 컬럼만 필터링
|
||||||
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
||||||
|
|
||||||
// 컬럼의 실제 웹 타입 정보 찾기
|
// 컬럼의 실제 웹 타입 정보 찾기 (webType 또는 input_type)
|
||||||
const getColumnWebType = useCallback(
|
const getColumnWebType = useCallback(
|
||||||
(columnName: string) => {
|
(columnName: string) => {
|
||||||
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
|
// 먼저 컴포넌트에 설정된 컬럼에서 찾기 (화면 관리에서 설정한 값 우선)
|
||||||
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
|
const componentColumn = component.columns?.find((col) => col.columnName === columnName);
|
||||||
if (componentColumn?.widgetType && componentColumn.widgetType !== "text") {
|
if (componentColumn?.widgetType && componentColumn.widgetType !== "text") {
|
||||||
|
console.log(`🔍 [${columnName}] componentColumn.widgetType 사용:`, componentColumn.widgetType);
|
||||||
return componentColumn.widgetType;
|
return componentColumn.widgetType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 없으면 테이블 타입 관리에서 설정된 값 찾기
|
// 없으면 테이블 타입 관리에서 설정된 값 찾기
|
||||||
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
|
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
|
||||||
return tableColumn?.webType || "text";
|
|
||||||
|
// input_type 우선 사용 (category 등)
|
||||||
|
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
|
||||||
|
if (inputType) {
|
||||||
|
console.log(`✅ [${columnName}] input_type 사용:`, inputType);
|
||||||
|
return inputType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 없으면 webType 사용
|
||||||
|
const result = tableColumn?.webType || "text";
|
||||||
|
console.log(`✅ [${columnName}] webType 사용:`, result);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
[component.columns, tableColumns],
|
[component.columns, tableColumns],
|
||||||
);
|
);
|
||||||
|
|
@ -1414,6 +1426,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "category": {
|
||||||
|
// 카테고리 셀렉트 (동적 import)
|
||||||
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
|
console.log("🎯 카테고리 렌더링 (편집 폼):", {
|
||||||
|
tableName: component.tableName,
|
||||||
|
columnName: column.columnName,
|
||||||
|
columnLabel: column.label,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CategorySelectComponent
|
||||||
|
tableName={component.tableName}
|
||||||
|
columnName={column.columnName}
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => handleEditFormChange(column.columnName, newValue)}
|
||||||
|
placeholder={advancedConfig?.placeholder || `${column.label} 선택...`}
|
||||||
|
required={isRequired}
|
||||||
|
className={commonProps.className}
|
||||||
|
/>
|
||||||
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1676,6 +1713,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "category": {
|
||||||
|
// 카테고리 셀렉트 (동적 import)
|
||||||
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
|
console.log("🎯 카테고리 렌더링 (추가 폼):", {
|
||||||
|
tableName: component.tableName,
|
||||||
|
columnName: column.columnName,
|
||||||
|
columnLabel: column.label,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CategorySelectComponent
|
||||||
|
tableName={component.tableName}
|
||||||
|
columnName={column.columnName}
|
||||||
|
value={value}
|
||||||
|
onChange={(newValue) => handleAddFormChange(column.columnName, newValue)}
|
||||||
|
placeholder={advancedConfig?.placeholder || `${column.label} 선택...`}
|
||||||
|
required={isRequired}
|
||||||
|
className={commonProps.className}
|
||||||
|
/>
|
||||||
|
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -345,26 +345,26 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
{assignmentSuccess ? (
|
{assignmentSuccess ? (
|
||||||
// 성공 화면
|
// 성공 화면
|
||||||
<>
|
<>
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||||
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"}
|
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<ResizableDialogDescription>
|
<DialogDescription>
|
||||||
{assignmentMessage.includes("나중에")
|
{assignmentMessage.includes("나중에")
|
||||||
? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다."
|
? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다."
|
||||||
: "화면이 성공적으로 메뉴에 할당되었습니다."}
|
: "화면이 성공적으로 메뉴에 할당되었습니다."}
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg border bg-green-50 p-4">
|
<div className="rounded-lg border bg-green-50 p-4">
|
||||||
|
|
@ -386,7 +386,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 타이머 정리
|
// 타이머 정리
|
||||||
|
|
@ -407,19 +407,19 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
<Monitor className="mr-2 h-4 w-4" />
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
화면 목록으로 이동
|
화면 목록으로 이동
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// 기본 할당 화면
|
// 기본 할당 화면
|
||||||
<>
|
<>
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
메뉴에 화면 할당
|
메뉴에 화면 할당
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<ResizableDialogDescription>
|
<DialogDescription>
|
||||||
저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다.
|
저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다.
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
{screenInfo && (
|
{screenInfo && (
|
||||||
<div className="bg-accent mt-2 rounded-lg border p-3">
|
<div className="bg-accent mt-2 rounded-lg border p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -432,7 +432,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
{screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>}
|
{screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 메뉴 선택 (검색 기능 포함) */}
|
{/* 메뉴 선택 (검색 기능 포함) */}
|
||||||
|
|
@ -572,22 +572,22 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 화면 교체 확인 대화상자 */}
|
{/* 화면 교체 확인 대화상자 */}
|
||||||
<ResizableDialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
|
<Dialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
|
||||||
<ResizableDialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Monitor className="h-5 w-5 text-orange-600" />
|
<Monitor className="h-5 w-5 text-orange-600" />
|
||||||
화면 교체 확인
|
화면 교체 확인
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<ResizableDialogDescription>선택한 메뉴에 이미 할당된 화면이 있습니다.</ResizableDialogDescription>
|
<DialogDescription>선택한 메뉴에 이미 할당된 화면이 있습니다.</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 기존 화면 목록 */}
|
{/* 기존 화면 목록 */}
|
||||||
|
|
@ -652,9 +652,9 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2555,6 +2555,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: componentId, // text-input, number-input 등
|
||||||
webType: column.widgetType, // 원본 웹타입 보존
|
webType: column.widgetType, // 원본 웹타입 보존
|
||||||
|
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
||||||
...getDefaultWebTypeConfig(column.widgetType),
|
...getDefaultWebTypeConfig(column.widgetType),
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
|
|
@ -2618,6 +2619,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: componentId, // text-input, number-input 등
|
||||||
webType: column.widgetType, // 원본 웹타입 보존
|
webType: column.widgetType, // 원본 웹타입 보존
|
||||||
|
inputType: column.inputType, // ✅ input_type 추가 (category 등)
|
||||||
...getDefaultWebTypeConfig(column.widgetType),
|
...getDefaultWebTypeConfig(column.widgetType),
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,24 @@ import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertResizableDialogContent,
|
AlertDialogContent,
|
||||||
AlertResizableDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertResizableDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
ResizableDialog,
|
||||||
|
ResizableDialogContent,
|
||||||
|
ResizableDialogHeader
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
|
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
|
||||||
|
|
@ -6,49 +6,26 @@ import { CategoryValueManager } from "@/components/table-category/CategoryValueM
|
||||||
|
|
||||||
interface CategoryWidgetProps {
|
interface CategoryWidgetProps {
|
||||||
widgetId: string;
|
widgetId: string;
|
||||||
menuId?: number; // 현재 화면의 menuId (선택사항)
|
|
||||||
tableName: string; // 현재 화면의 테이블
|
tableName: string; // 현재 화면의 테이블
|
||||||
selectedScreen?: any; // 화면 정보 전체 (menuId 추출용)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 관리 위젯 (좌우 분할)
|
* 카테고리 관리 위젯 (좌우 분할)
|
||||||
* - 좌측: 현재 테이블의 카테고리 타입 컬럼 목록
|
* - 좌측: 현재 테이블의 카테고리 타입 컬럼 목록
|
||||||
* - 우측: 선택된 컬럼의 카테고리 값 관리
|
* - 우측: 선택된 컬럼의 카테고리 값 관리 (테이블 스코프)
|
||||||
*/
|
*/
|
||||||
export function CategoryWidget({
|
export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
|
||||||
widgetId,
|
|
||||||
menuId: propMenuId,
|
|
||||||
tableName,
|
|
||||||
selectedScreen,
|
|
||||||
}: CategoryWidgetProps) {
|
|
||||||
const [selectedColumn, setSelectedColumn] = useState<{
|
const [selectedColumn, setSelectedColumn] = useState<{
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// menuId 추출: props > selectedScreen > 기본값(1)
|
|
||||||
const menuId =
|
|
||||||
propMenuId ||
|
|
||||||
selectedScreen?.menuId ||
|
|
||||||
selectedScreen?.menu_id ||
|
|
||||||
1; // 기본값
|
|
||||||
|
|
||||||
// menuId가 없으면 경고 메시지 표시
|
|
||||||
if (!menuId || menuId === 1) {
|
|
||||||
console.warn("⚠️ CategoryWidget: menuId가 제공되지 않아 기본값(1)을 사용합니다", {
|
|
||||||
propMenuId,
|
|
||||||
selectedScreen,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[10px] gap-6">
|
<div className="flex h-full min-h-[10px] gap-6">
|
||||||
{/* 좌측: 카테고리 컬럼 리스트 (30%) */}
|
{/* 좌측: 카테고리 컬럼 리스트 (30%) */}
|
||||||
<div className="w-[30%] border-r pr-6">
|
<div className="w-[30%] border-r pr-6">
|
||||||
<CategoryColumnList
|
<CategoryColumnList
|
||||||
tableName={tableName}
|
tableName={tableName}
|
||||||
menuId={menuId}
|
|
||||||
selectedColumn={selectedColumn?.columnName || null}
|
selectedColumn={selectedColumn?.columnName || null}
|
||||||
onColumnSelect={(columnName, columnLabel) =>
|
onColumnSelect={(columnName, columnLabel) =>
|
||||||
setSelectedColumn({ columnName, columnLabel })
|
setSelectedColumn({ columnName, columnLabel })
|
||||||
|
|
@ -63,7 +40,6 @@ export function CategoryWidget({
|
||||||
tableName={tableName}
|
tableName={tableName}
|
||||||
columnName={selectedColumn.columnName}
|
columnName={selectedColumn.columnName}
|
||||||
columnLabel={selectedColumn.columnLabel}
|
columnLabel={selectedColumn.columnLabel}
|
||||||
menuId={menuId}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,16 @@ interface CategoryColumn {
|
||||||
|
|
||||||
interface CategoryColumnListProps {
|
interface CategoryColumnListProps {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
menuId: number;
|
|
||||||
selectedColumn: string | null;
|
selectedColumn: string | null;
|
||||||
onColumnSelect: (columnName: string, columnLabel: string) => void;
|
onColumnSelect: (columnName: string, columnLabel: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 컬럼 목록 (좌측 패널)
|
* 카테고리 컬럼 목록 (좌측 패널)
|
||||||
* - 현재 테이블에서 input_type='category'인 컬럼들을 표시
|
* - 현재 테이블에서 input_type='category'인 컬럼들을 표시 (테이블 스코프)
|
||||||
*/
|
*/
|
||||||
export function CategoryColumnList({
|
export function CategoryColumnList({
|
||||||
tableName,
|
tableName,
|
||||||
menuId,
|
|
||||||
selectedColumn,
|
selectedColumn,
|
||||||
onColumnSelect,
|
onColumnSelect,
|
||||||
}: CategoryColumnListProps) {
|
}: CategoryColumnListProps) {
|
||||||
|
|
@ -32,7 +30,7 @@ export function CategoryColumnList({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCategoryColumns();
|
loadCategoryColumns();
|
||||||
}, [tableName, menuId]);
|
}, [tableName]);
|
||||||
|
|
||||||
const loadCategoryColumns = async () => {
|
const loadCategoryColumns = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||||
|
|
||||||
interface CategoryValueAddDialogProps {
|
interface CategoryValueAddDialogProps {
|
||||||
|
|
@ -26,33 +24,48 @@ interface CategoryValueAddDialogProps {
|
||||||
export const CategoryValueAddDialog: React.FC<
|
export const CategoryValueAddDialog: React.FC<
|
||||||
CategoryValueAddDialogProps
|
CategoryValueAddDialogProps
|
||||||
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
|
> = ({ open, onOpenChange, onAdd, columnLabel }) => {
|
||||||
const [valueCode, setValueCode] = useState("");
|
|
||||||
const [valueLabel, setValueLabel] = useState("");
|
const [valueLabel, setValueLabel] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [color, setColor] = useState("#3b82f6");
|
|
||||||
const [isDefault, setIsDefault] = useState(false);
|
// 라벨에서 코드 자동 생성
|
||||||
|
const generateCode = (label: string): string => {
|
||||||
|
// 한글을 영문으로 변환하거나, 영문/숫자만 추출하여 대문자로
|
||||||
|
const cleaned = label
|
||||||
|
.replace(/[^a-zA-Z0-9가-힣\s]/g, "") // 특수문자 제거
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
// 영문이 있으면 영문만, 없으면 타임스탬프 기반
|
||||||
|
const englishOnly = cleaned.replace(/[^A-Z0-9\s]/g, "").replace(/\s+/g, "_");
|
||||||
|
|
||||||
|
if (englishOnly.length > 0) {
|
||||||
|
return englishOnly.substring(0, 20); // 최대 20자
|
||||||
|
}
|
||||||
|
|
||||||
|
// 영문이 없으면 CATEGORY_TIMESTAMP 형식
|
||||||
|
return `CATEGORY_${Date.now().toString().slice(-6)}`;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!valueCode || !valueLabel) {
|
if (!valueLabel.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const valueCode = generateCode(valueLabel);
|
||||||
|
|
||||||
onAdd({
|
onAdd({
|
||||||
tableName: "",
|
tableName: "",
|
||||||
columnName: "",
|
columnName: "",
|
||||||
valueCode: valueCode.toUpperCase(),
|
valueCode,
|
||||||
valueLabel,
|
valueLabel: valueLabel.trim(),
|
||||||
description,
|
description: description.trim(),
|
||||||
color,
|
color: "#3b82f6",
|
||||||
isDefault,
|
isDefault: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 초기화
|
// 초기화
|
||||||
setValueCode("");
|
|
||||||
setValueLabel("");
|
setValueLabel("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setColor("#3b82f6");
|
|
||||||
setIsDefault(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -68,83 +81,23 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
<Input
|
||||||
<Label htmlFor="valueCode" className="text-xs sm:text-sm">
|
id="valueLabel"
|
||||||
코드 *
|
placeholder="이름 (예: 개발, 긴급, 진행중)"
|
||||||
</Label>
|
value={valueLabel}
|
||||||
<Input
|
onChange={(e) => setValueLabel(e.target.value)}
|
||||||
id="valueCode"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
placeholder="예: DEV, URGENT"
|
autoFocus
|
||||||
value={valueCode}
|
/>
|
||||||
onChange={(e) => setValueCode(e.target.value.toUpperCase())}
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
||||||
영문 대문자와 언더스코어만 사용 (DB 저장값)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Textarea
|
||||||
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
id="description"
|
||||||
라벨 *
|
placeholder="설명 (선택사항)"
|
||||||
</Label>
|
value={description}
|
||||||
<Input
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
id="valueLabel"
|
className="text-xs sm:text-sm"
|
||||||
placeholder="예: 개발, 긴급"
|
rows={3}
|
||||||
value={valueLabel}
|
/>
|
||||||
onChange={(e) => setValueLabel(e.target.value)}
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
||||||
사용자에게 표시될 이름
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
|
||||||
설명
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
placeholder="상세 설명 (선택사항)"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="color" className="text-xs sm:text-sm">
|
|
||||||
색상
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="color"
|
|
||||||
type="color"
|
|
||||||
value={color}
|
|
||||||
onChange={(e) => setColor(e.target.value)}
|
|
||||||
className="h-8 w-16 sm:h-10"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={color}
|
|
||||||
onChange={(e) => setColor(e.target.value)}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isDefault"
|
|
||||||
checked={isDefault}
|
|
||||||
onCheckedChange={(checked) => setIsDefault(checked as boolean)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="isDefault" className="text-xs sm:text-sm">
|
|
||||||
기본값으로 설정
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
|
@ -157,7 +110,7 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!valueCode || !valueLabel}
|
disabled={!valueLabel.trim()}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
추가
|
추가
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||||
|
|
||||||
interface CategoryValueEditDialogProps {
|
interface CategoryValueEditDialogProps {
|
||||||
|
|
@ -29,29 +27,20 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
|
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
|
||||||
const [valueLabel, setValueLabel] = useState(value.valueLabel);
|
const [valueLabel, setValueLabel] = useState(value.valueLabel);
|
||||||
const [description, setDescription] = useState(value.description || "");
|
const [description, setDescription] = useState(value.description || "");
|
||||||
const [color, setColor] = useState(value.color || "#3b82f6");
|
|
||||||
const [isDefault, setIsDefault] = useState(value.isDefault || false);
|
|
||||||
const [isActive, setIsActive] = useState(value.isActive !== false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValueLabel(value.valueLabel);
|
setValueLabel(value.valueLabel);
|
||||||
setDescription(value.description || "");
|
setDescription(value.description || "");
|
||||||
setColor(value.color || "#3b82f6");
|
|
||||||
setIsDefault(value.isDefault || false);
|
|
||||||
setIsActive(value.isActive !== false);
|
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!valueLabel) {
|
if (!valueLabel.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(value.valueId!, {
|
onUpdate(value.valueId!, {
|
||||||
valueLabel,
|
valueLabel: valueLabel.trim(),
|
||||||
description,
|
description: description.trim(),
|
||||||
color,
|
|
||||||
isDefault,
|
|
||||||
isActive,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -68,88 +57,23 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
<Input
|
||||||
<Label htmlFor="valueCode" className="text-xs sm:text-sm">
|
id="valueLabel"
|
||||||
코드
|
placeholder="이름 (예: 개발, 긴급, 진행중)"
|
||||||
</Label>
|
value={valueLabel}
|
||||||
<Input
|
onChange={(e) => setValueLabel(e.target.value)}
|
||||||
id="valueCode"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
value={value.valueCode}
|
autoFocus
|
||||||
disabled
|
/>
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
||||||
코드는 변경할 수 없습니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Textarea
|
||||||
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
id="description"
|
||||||
라벨 *
|
placeholder="설명 (선택사항)"
|
||||||
</Label>
|
value={description}
|
||||||
<Input
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
id="valueLabel"
|
className="text-xs sm:text-sm"
|
||||||
value={valueLabel}
|
rows={3}
|
||||||
onChange={(e) => setValueLabel(e.target.value)}
|
/>
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
|
||||||
설명
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="color" className="text-xs sm:text-sm">
|
|
||||||
색상
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="color"
|
|
||||||
type="color"
|
|
||||||
value={color}
|
|
||||||
onChange={(e) => setColor(e.target.value)}
|
|
||||||
className="h-8 w-16 sm:h-10"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={color}
|
|
||||||
onChange={(e) => setColor(e.target.value)}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isDefault"
|
|
||||||
checked={isDefault}
|
|
||||||
onCheckedChange={(checked) => setIsDefault(checked as boolean)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="isDefault" className="text-xs sm:text-sm">
|
|
||||||
기본값으로 설정
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="isActive"
|
|
||||||
checked={isActive}
|
|
||||||
onCheckedChange={(checked) => setIsActive(checked as boolean)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="isActive" className="text-xs sm:text-sm">
|
|
||||||
활성화
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
|
@ -162,7 +86,7 @@ export const CategoryValueEditDialog: React.FC<
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!valueLabel}
|
disabled={!valueLabel.trim()}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
저장
|
저장
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,12 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
Edit2,
|
Edit2,
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getCategoryValues,
|
getCategoryValues,
|
||||||
|
|
@ -29,7 +28,6 @@ interface CategoryValueManagerProps {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
menuId: number; // 메뉴 스코프
|
|
||||||
onValueCountChange?: (count: number) => void;
|
onValueCountChange?: (count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,7 +35,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
columnLabel,
|
columnLabel,
|
||||||
menuId,
|
|
||||||
onValueCountChange,
|
onValueCountChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -56,7 +53,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
// 카테고리 값 로드
|
// 카테고리 값 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCategoryValues();
|
loadCategoryValues();
|
||||||
}, [tableName, columnName, menuId]);
|
}, [tableName, columnName]);
|
||||||
|
|
||||||
// 검색 필터링
|
// 검색 필터링
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -75,7 +72,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
const loadCategoryValues = async () => {
|
const loadCategoryValues = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getCategoryValues(tableName, columnName, menuId);
|
const response = await getCategoryValues(tableName, columnName);
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setValues(response.data);
|
setValues(response.data);
|
||||||
setFilteredValues(response.data);
|
setFilteredValues(response.data);
|
||||||
|
|
@ -99,7 +96,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
...newValue,
|
...newValue,
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
menuId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
|
|
@ -109,11 +105,19 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
title: "성공",
|
title: "성공",
|
||||||
description: "카테고리 값이 추가되었습니다",
|
description: "카테고리 값이 추가되었습니다",
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
console.error("❌ 카테고리 값 추가 실패:", response);
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: response.error || "카테고리 값 추가에 실패했습니다",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
console.error("❌ 카테고리 값 추가 예외:", error);
|
||||||
toast({
|
toast({
|
||||||
title: "오류",
|
title: "오류",
|
||||||
description: "카테고리 값 추가에 실패했습니다",
|
description: error.message || "카테고리 값 추가에 실패했습니다",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -219,6 +223,35 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (valueId: number, currentIsActive: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await updateCategoryValue(valueId, {
|
||||||
|
isActive: !currentIsActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
await loadCategoryValues();
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: `카테고리 값이 ${!currentIsActive ? "활성화" : "비활성화"}되었습니다`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: response.error || "상태 변경에 실패했습니다",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 활성 상태 변경 실패:", error);
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: error.message || "상태 변경에 실패했습니다",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
@ -270,39 +303,42 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
onCheckedChange={() => handleSelectValue(value.valueId!)}
|
onCheckedChange={() => handleSelectValue(value.valueId!)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Badge variant="outline" className="text-xs">
|
||||||
<Badge variant="outline" className="text-xs">
|
{value.valueCode}
|
||||||
{value.valueCode}
|
</Badge>
|
||||||
</Badge>
|
<span className="text-sm font-medium">
|
||||||
<span className="text-sm font-medium">
|
{value.valueLabel}
|
||||||
{value.valueLabel}
|
</span>
|
||||||
</span>
|
|
||||||
{value.isDefault && (
|
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
|
||||||
기본값
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{value.color && (
|
|
||||||
<div
|
|
||||||
className="h-4 w-4 rounded-full border"
|
|
||||||
style={{ backgroundColor: value.color }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{value.description && (
|
{value.description && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{value.description}
|
- {value.description}
|
||||||
</p>
|
</span>
|
||||||
|
)}
|
||||||
|
{value.isDefault && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
기본값
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{value.color && (
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-full border"
|
||||||
|
style={{ backgroundColor: value.color }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{value.isActive ? (
|
<Switch
|
||||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
checked={value.isActive !== false}
|
||||||
) : (
|
onCheckedChange={() =>
|
||||||
<XCircle className="h-4 w-4 text-destructive" />
|
handleToggleActive(
|
||||||
)}
|
value.valueId!,
|
||||||
|
value.isActive !== false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="data-[state=checked]:bg-emerald-500"
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,11 @@ export async function getCategoryColumns(tableName: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
* 카테고리 값 목록 조회 (테이블 스코프)
|
||||||
*/
|
*/
|
||||||
export async function getCategoryValues(
|
export async function getCategoryValues(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
menuId: number,
|
|
||||||
includeInactive: boolean = false
|
includeInactive: boolean = false
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -34,7 +33,7 @@ export async function getCategoryValues(
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: TableCategoryValue[];
|
data: TableCategoryValue[];
|
||||||
}>(`/table-categories/${tableName}/${columnName}/values`, {
|
}>(`/table-categories/${tableName}/${columnName}/values`, {
|
||||||
params: { menuId, includeInactive },
|
params: { includeInactive },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,53 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||||
const componentType = (component as any).componentType || component.type;
|
const componentType = (component as any).componentType || component.type;
|
||||||
|
|
||||||
|
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
|
||||||
|
const inputType = (component as any).componentConfig?.inputType || (component as any).inputType;
|
||||||
|
const webType = (component as any).componentConfig?.webType;
|
||||||
|
const tableName = (component as any).tableName;
|
||||||
|
const columnName = (component as any).columnName;
|
||||||
|
|
||||||
|
console.log("🔍 DynamicComponentRenderer 컴포넌트 타입 확인:", {
|
||||||
|
componentId: component.id,
|
||||||
|
componentType,
|
||||||
|
inputType,
|
||||||
|
webType,
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
componentConfig: (component as any).componentConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||||
|
if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||||
|
console.log("✅ 카테고리 타입 감지 → CategorySelectComponent 렌더링");
|
||||||
|
try {
|
||||||
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
|
const fieldName = columnName || component.id;
|
||||||
|
const currentValue = props.formData?.[fieldName] || "";
|
||||||
|
|
||||||
|
const handleChange = (value: any) => {
|
||||||
|
if (props.onFormDataChange) {
|
||||||
|
props.onFormDataChange(fieldName, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CategorySelectComponent
|
||||||
|
tableName={tableName}
|
||||||
|
columnName={columnName}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={component.componentConfig?.placeholder || "선택하세요"}
|
||||||
|
required={(component as any).required}
|
||||||
|
disabled={(component as any).readonly}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ CategorySelectComponent 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 레이아웃 컴포넌트 처리
|
// 레이아웃 컴포넌트 처리
|
||||||
if (componentType === "layout") {
|
if (componentType === "layout") {
|
||||||
// DOM 안전한 props만 전달
|
// DOM 안전한 props만 전달
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,13 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
return <DateInputComponent {...props} {...finalProps} />;
|
return <DateInputComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 셀렉트 웹타입
|
||||||
|
if (webType === "category") {
|
||||||
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
|
console.log(`✅ 폴백: ${webType} 웹타입 → CategorySelectComponent 사용`);
|
||||||
|
return <CategorySelectComponent {...props} {...finalProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
// 기본 폴백: Input 컴포넌트 사용
|
// 기본 폴백: Input 컴포넌트 사용
|
||||||
const { Input } = require("@/components/ui/input");
|
const { Input } = require("@/components/ui/input");
|
||||||
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
|
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
export const getComponentWebType = (component: ComponentData): string | undefined => {
|
||||||
if (!component || !component.type) return undefined;
|
if (!component || !component.type) return undefined;
|
||||||
|
|
@ -80,13 +80,49 @@ export const getComponentWebType = (component: ComponentData): string | undefine
|
||||||
return "file";
|
return "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (component.type === "widget") {
|
// 1. componentConfig.inputType 우선 확인 (새로 추가된 컴포넌트)
|
||||||
return (component as any).widgetType;
|
const configInputType = (component as any).componentConfig?.inputType;
|
||||||
|
if (configInputType) {
|
||||||
|
console.log(`✅ 컴포넌트 componentConfig.inputType 사용:`, {
|
||||||
|
componentId: component.id,
|
||||||
|
tableName: (component as any).tableName,
|
||||||
|
columnName: (component as any).columnName,
|
||||||
|
inputType: configInputType,
|
||||||
|
componentConfig: (component as any).componentConfig,
|
||||||
|
});
|
||||||
|
return configInputType;
|
||||||
}
|
}
|
||||||
if (component.type === "component") {
|
|
||||||
return (component as any).widgetType || (component as any).componentConfig?.webType;
|
// 2. 루트 레벨 input_type 확인 (하위 호환성)
|
||||||
|
const rootInputType = (component as any).input_type || (component as any).inputType;
|
||||||
|
if (rootInputType) {
|
||||||
|
console.log(`✅ 컴포넌트 루트 inputType 사용:`, {
|
||||||
|
componentId: component.id,
|
||||||
|
tableName: (component as any).tableName,
|
||||||
|
columnName: (component as any).columnName,
|
||||||
|
inputType: rootInputType,
|
||||||
|
});
|
||||||
|
return rootInputType;
|
||||||
}
|
}
|
||||||
return component.type;
|
|
||||||
|
// 3. 기본 웹타입 확인
|
||||||
|
const webType = component.type === "widget"
|
||||||
|
? (component as any).widgetType
|
||||||
|
: component.type === "component"
|
||||||
|
? (component as any).widgetType || (component as any).componentConfig?.webType
|
||||||
|
: component.type;
|
||||||
|
|
||||||
|
console.log(`⚠️ inputType 없음, 기본 webType 사용:`, {
|
||||||
|
componentId: component.id,
|
||||||
|
tableName: (component as any).tableName,
|
||||||
|
columnName: (component as any).columnName,
|
||||||
|
type: component.type,
|
||||||
|
widgetType: (component as any).widgetType,
|
||||||
|
componentConfig: (component as any).componentConfig,
|
||||||
|
resultWebType: webType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return webType;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
||||||
label: "text-display",
|
label: "text-display",
|
||||||
code: "select-basic", // 코드 타입은 선택상자 사용
|
code: "select-basic", // 코드 타입은 선택상자 사용
|
||||||
entity: "select-basic", // 엔티티 타입은 선택상자 사용
|
entity: "select-basic", // 엔티티 타입은 선택상자 사용
|
||||||
|
category: "select-basic", // 카테고리 타입은 선택상자 사용
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,6 @@ export interface TableCategoryValue {
|
||||||
// 하위 항목
|
// 하위 항목
|
||||||
children?: TableCategoryValue[];
|
children?: TableCategoryValue[];
|
||||||
|
|
||||||
// 메뉴 스코프
|
|
||||||
menuId: number;
|
|
||||||
|
|
||||||
// 멀티테넌시
|
// 멀티테넌시
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue