메뉴생성시 화면할당기능 구현
This commit is contained in:
parent
d1e1c7964b
commit
84d4d49bd5
|
|
@ -3,14 +3,18 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { MenuItem, MenuFormData, menuApi, LangKey } from "@/lib/api/menu";
|
||||
import { companyAPI } from "@/lib/api/company";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronDown, Search } from "lucide-react";
|
||||
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
|
||||
interface Company {
|
||||
company_code: string;
|
||||
|
|
@ -70,6 +74,13 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
langKey: "",
|
||||
});
|
||||
|
||||
// 화면 할당 관련 상태
|
||||
const [urlType, setUrlType] = useState<"direct" | "screen">("screen"); // URL 직접 입력 or 화면 할당 (기본값: 화면 할당)
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [screenSearchText, setScreenSearchText] = useState("");
|
||||
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
|
|
@ -77,6 +88,132 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
const [isLangKeyDropdownOpen, setIsLangKeyDropdownOpen] = useState(false);
|
||||
const [langKeySearchText, setLangKeySearchText] = useState("");
|
||||
|
||||
// 화면 목록 로드
|
||||
const loadScreens = async () => {
|
||||
try {
|
||||
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
|
||||
|
||||
console.log("🔍 화면 목록 로드 디버깅:", {
|
||||
totalScreens: response.data.length,
|
||||
firstScreen: response.data[0],
|
||||
firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [],
|
||||
firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [],
|
||||
allScreenIds: response.data
|
||||
.map((s) => ({
|
||||
screenId: s.screenId,
|
||||
legacyId: s.id,
|
||||
name: s.screenName,
|
||||
code: s.screenCode,
|
||||
}))
|
||||
.slice(0, 5), // 처음 5개만 출력
|
||||
});
|
||||
|
||||
setScreens(response.data);
|
||||
console.log("✅ 화면 목록 로드 완료:", response.data.length);
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 목록 로드 실패:", error);
|
||||
toast.error("화면 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 선택 시 URL 자동 설정
|
||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||
console.log("🖥️ 화면 선택 디버깅:", {
|
||||
screen,
|
||||
screenId: screen.screenId,
|
||||
screenIdType: typeof screen.screenId,
|
||||
legacyId: screen.id,
|
||||
allFields: Object.keys(screen),
|
||||
screenValues: Object.values(screen),
|
||||
});
|
||||
|
||||
// ScreenDefinition에서는 screenId 필드를 사용
|
||||
const actualScreenId = screen.screenId || screen.id;
|
||||
|
||||
if (!actualScreenId) {
|
||||
console.error("❌ 화면 ID를 찾을 수 없습니다:", screen);
|
||||
toast.error("화면 ID를 찾을 수 없습니다. 다른 화면을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedScreen(screen);
|
||||
setIsScreenDropdownOpen(false);
|
||||
|
||||
// 실제 라우팅 패턴에 맞게 URL 생성: /screens/[screenId] (복수형)
|
||||
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
|
||||
let screenUrl = `/screens/${actualScreenId}`;
|
||||
|
||||
// 현재 메뉴 타입이 관리자인지 확인 (0 또는 "admin")
|
||||
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
||||
if (isAdminMenu) {
|
||||
screenUrl += "?mode=admin";
|
||||
}
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: screenUrl,
|
||||
}));
|
||||
|
||||
console.log("🖥️ 화면 선택 완료:", {
|
||||
screenId: screen.screenId,
|
||||
legacyId: screen.id,
|
||||
actualScreenId,
|
||||
screenName: screen.screenName,
|
||||
menuType: menuType,
|
||||
formDataMenuType: formData.menuType,
|
||||
isAdminMenu,
|
||||
generatedUrl: screenUrl,
|
||||
});
|
||||
};
|
||||
|
||||
// URL 타입 변경 시 처리
|
||||
const handleUrlTypeChange = (type: "direct" | "screen") => {
|
||||
console.log("🔄 URL 타입 변경:", {
|
||||
from: urlType,
|
||||
to: type,
|
||||
currentSelectedScreen: selectedScreen?.screenName,
|
||||
currentUrl: formData.menuUrl,
|
||||
});
|
||||
|
||||
setUrlType(type);
|
||||
|
||||
if (type === "direct") {
|
||||
// 직접 입력 모드로 변경 시 선택된 화면 초기화
|
||||
setSelectedScreen(null);
|
||||
// URL 필드도 초기화 (사용자가 직접 입력할 수 있도록)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
}));
|
||||
} else {
|
||||
// 화면 할당 모드로 변경 시
|
||||
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
|
||||
if (selectedScreen) {
|
||||
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
|
||||
// 현재 선택된 화면으로 URL 재생성
|
||||
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
|
||||
let screenUrl = `/screens/${actualScreenId}`;
|
||||
|
||||
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
|
||||
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
||||
if (isAdminMenu) {
|
||||
screenUrl += "?mode=admin";
|
||||
}
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: screenUrl,
|
||||
}));
|
||||
} else {
|
||||
// 선택된 화면이 없으면 URL만 초기화
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// loadMenuData 함수를 먼저 정의
|
||||
const loadMenuData = async () => {
|
||||
console.log("loadMenuData 호출됨 - menuId:", menuId);
|
||||
|
|
@ -124,11 +261,16 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
convertedStatus = "INACTIVE";
|
||||
}
|
||||
|
||||
const menuUrl = menu.menu_url || menu.MENU_URL || "";
|
||||
|
||||
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
|
||||
const isScreenUrl = menuUrl.startsWith("/screens/");
|
||||
|
||||
setFormData({
|
||||
objid: menu.objid || menu.OBJID,
|
||||
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
||||
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
|
||||
menuUrl: menu.menu_url || menu.MENU_URL || "",
|
||||
menuUrl: menuUrl,
|
||||
menuDesc: menu.menu_desc || menu.MENU_DESC || "",
|
||||
seq: menu.seq || menu.SEQ || 1,
|
||||
menuType: convertedMenuType,
|
||||
|
|
@ -137,6 +279,57 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
langKey: langKey, // 다국어 키 설정
|
||||
});
|
||||
|
||||
// URL 타입 설정
|
||||
if (isScreenUrl) {
|
||||
setUrlType("screen");
|
||||
// "/screens/123" 또는 "/screens/123?mode=admin" 형태에서 ID 추출
|
||||
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
|
||||
if (screenId) {
|
||||
console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
|
||||
menuUrl,
|
||||
screenId,
|
||||
hasAdminParam: menuUrl.includes("mode=admin"),
|
||||
currentScreensCount: screens.length,
|
||||
});
|
||||
|
||||
// 화면 설정 함수
|
||||
const setScreenFromId = () => {
|
||||
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
||||
if (screen) {
|
||||
setSelectedScreen(screen);
|
||||
console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
|
||||
screen,
|
||||
originalUrl: menuUrl,
|
||||
hasAdminParam: menuUrl.includes("mode=admin"),
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
|
||||
screenId,
|
||||
availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 목록이 이미 있으면 즉시 설정, 없으면 로드 완료 대기
|
||||
if (screens.length > 0) {
|
||||
console.log("📋 화면 목록이 이미 로드됨 - 즉시 설정");
|
||||
setScreenFromId();
|
||||
} else {
|
||||
console.log("⏳ 화면 목록 로드 대기 중...");
|
||||
// 화면 ID를 저장해두고, 화면 목록 로드 완료 후 설정
|
||||
setTimeout(() => {
|
||||
console.log("🔄 재시도: 화면 목록 로드 후 설정");
|
||||
setScreenFromId();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUrlType("direct");
|
||||
setSelectedScreen(null);
|
||||
}
|
||||
|
||||
console.log("설정된 폼 데이터:", {
|
||||
objid: menu.objid || menu.OBJID,
|
||||
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
||||
|
|
@ -237,6 +430,35 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
}
|
||||
}, [isOpen, formData.companyCode]);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadScreens();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 화면 목록 로드 완료 후 기존 메뉴의 할당된 화면 설정
|
||||
useEffect(() => {
|
||||
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "screen") {
|
||||
const menuUrl = formData.menuUrl;
|
||||
if (menuUrl.startsWith("/screens/")) {
|
||||
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
|
||||
if (screenId && !selectedScreen) {
|
||||
console.log("🔄 화면 목록 로드 완료 - 기존 할당 화면 자동 설정");
|
||||
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
||||
if (screen) {
|
||||
setSelectedScreen(screen);
|
||||
console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
|
||||
screenId,
|
||||
screenName: screen.screenName,
|
||||
menuUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [screens, isEdit, formData.menuUrl, urlType, selectedScreen]);
|
||||
|
||||
// 드롭다운 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
|
@ -245,16 +467,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
setIsLangKeyDropdownOpen(false);
|
||||
setLangKeySearchText("");
|
||||
}
|
||||
if (!target.closest(".screen-dropdown")) {
|
||||
setIsScreenDropdownOpen(false);
|
||||
setScreenSearchText("");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLangKeyDropdownOpen) {
|
||||
if (isLangKeyDropdownOpen || isScreenDropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isLangKeyDropdownOpen]);
|
||||
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
|
|
@ -516,12 +742,108 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||
|
||||
{/* URL 타입 선택 */}
|
||||
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="screen" id="screen" />
|
||||
<Label htmlFor="screen" className="cursor-pointer">
|
||||
화면 할당
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="direct" id="direct" />
|
||||
<Label htmlFor="direct" className="cursor-pointer">
|
||||
URL 직접 입력
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{/* 화면 할당 */}
|
||||
{urlType === "screen" && (
|
||||
<div className="space-y-2">
|
||||
{/* 화면 선택 드롭다운 */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsScreenDropdownOpen(!isScreenDropdownOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="text-left">
|
||||
{selectedScreen ? selectedScreen.screenName : "화면을 선택하세요"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{isScreenDropdownOpen && (
|
||||
<div className="screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
|
||||
{/* 검색 입력 */}
|
||||
<div className="sticky top-0 border-b bg-white p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={screenSearchText}
|
||||
onChange={(e) => setScreenSearchText(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화면 목록 */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{screens
|
||||
.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()),
|
||||
)
|
||||
.map((screen, index) => (
|
||||
<div
|
||||
key={`screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
|
||||
onClick={() => handleScreenSelect(screen)}
|
||||
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{screen.screenName}</div>
|
||||
<div className="text-xs text-gray-500">{screen.screenCode}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{screens.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()),
|
||||
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500">검색 결과가 없습니다.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 화면 정보 표시 */}
|
||||
{selectedScreen && (
|
||||
<div className="rounded-md border bg-blue-50 p-3">
|
||||
<div className="text-sm font-medium text-blue-900">{selectedScreen.screenName}</div>
|
||||
<div className="text-xs text-blue-600">코드: {selectedScreen.screenCode}</div>
|
||||
<div className="text-xs text-blue-600">생성된 URL: {formData.menuUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL 직접 입력 */}
|
||||
{urlType === "direct" && (
|
||||
<Input
|
||||
id="menuUrl"
|
||||
value={formData.menuUrl}
|
||||
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
|
||||
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -406,7 +406,32 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
|
||||
const options = getAllOptions();
|
||||
const selectedOption = options.find((option) => option.value === selectedValue);
|
||||
const newLabel = selectedOption?.label || "";
|
||||
|
||||
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
|
||||
let newLabel = selectedOption?.label || "";
|
||||
|
||||
// selectedOption이 없고 selectedValue가 있다면, 코드명으로도 검색해보기
|
||||
if (!selectedOption && selectedValue && codeOptions.length > 0) {
|
||||
// 1) selectedValue가 코드명인 경우 (예: "국내")
|
||||
const labelMatch = options.find((option) => option.label === selectedValue);
|
||||
if (labelMatch) {
|
||||
newLabel = labelMatch.label;
|
||||
console.log(`🔍 [${component.id}] 코드명으로 매치 발견: "${selectedValue}" → "${newLabel}"`);
|
||||
} else {
|
||||
// 2) selectedValue가 코드값인 경우라면 원래 로직대로 라벨을 찾되, 없으면 원값 표시
|
||||
newLabel = selectedValue; // 코드값 그대로 표시 (예: "555")
|
||||
console.log(`🔍 [${component.id}] 코드값 원본 유지: "${selectedValue}"`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🏷️ [${component.id}] 라벨 업데이트:`, {
|
||||
selectedValue,
|
||||
selectedOption: selectedOption ? { value: selectedOption.value, label: selectedOption.label } : null,
|
||||
newLabel,
|
||||
optionsCount: options.length,
|
||||
allOptionsValues: options.map((o) => o.value),
|
||||
allOptionsLabels: options.map((o) => o.label),
|
||||
});
|
||||
|
||||
if (newLabel !== selectedLabel) {
|
||||
setSelectedLabel(newLabel);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,605 @@
|
|||
# 화면관리 시스템 타입 문제 분석 및 해결방안
|
||||
|
||||
## 📋 현재 상황 분석
|
||||
|
||||
### 주요 시스템들
|
||||
|
||||
1. **화면관리 시스템** (Screen Management)
|
||||
2. **제어관리 시스템** (Button Dataflow Control)
|
||||
3. **테이블 타입관리 시스템** (Table Type Management)
|
||||
|
||||
### 발견된 문제점들
|
||||
|
||||
## 🚨 1. 타입 정의 분산 및 중복 문제
|
||||
|
||||
### 1.1 WebType 타입 정의 분산
|
||||
|
||||
**문제**: WebType이 여러 파일에서 서로 다르게 정의되어 불일치 발생
|
||||
|
||||
#### 현재 상황:
|
||||
|
||||
- `frontend/types/screen.ts`: 화면관리용 WebType 정의
|
||||
- `backend-node/src/types/tableManagement.ts`: 테이블관리용 타입 정의
|
||||
- `backend-node/prisma/schema.prisma`: DB 스키마의 web_type_standards 모델
|
||||
- `frontend/lib/registry/types.ts`: 레지스트리용 WebType 정의
|
||||
|
||||
#### 구체적 충돌 사례:
|
||||
|
||||
```typescript
|
||||
// frontend/types/screen.ts
|
||||
export type WebType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "date"
|
||||
| "code"
|
||||
| "entity"
|
||||
| "textarea"
|
||||
| "boolean"
|
||||
| "decimal"
|
||||
| "button"
|
||||
| "datetime"
|
||||
| "dropdown"
|
||||
| "text_area"
|
||||
| "checkbox"
|
||||
| "radio"
|
||||
| "file"
|
||||
| "email"
|
||||
| "tel"
|
||||
| "url";
|
||||
|
||||
// 실제 DB에서는 다른 web_type 값들이 존재할 수 있음
|
||||
// 예: "varchar", "integer", "timestamp" 등
|
||||
```
|
||||
|
||||
### 1.2 ButtonActionType 중복 정의
|
||||
|
||||
**문제**: 버튼 액션 타입이 여러 곳에서 다르게 정의됨
|
||||
|
||||
#### 충돌 위치:
|
||||
|
||||
- `frontend/types/screen.ts`: `"control"` 포함, `"modal"` 포함
|
||||
- `frontend/lib/utils/buttonActions.ts`: `"cancel"` 포함, `"modal"` 포함
|
||||
- `frontend/hooks/admin/useButtonActions.ts`: DB 스키마 기반 정의
|
||||
|
||||
#### 문제 코드:
|
||||
|
||||
```typescript
|
||||
// frontend/types/screen.ts
|
||||
export type ButtonActionType =
|
||||
| "save"
|
||||
| "delete"
|
||||
| "edit"
|
||||
| "add"
|
||||
| "search"
|
||||
| "reset"
|
||||
| "submit"
|
||||
| "close"
|
||||
| "popup"
|
||||
| "modal"
|
||||
| "newWindow"
|
||||
| "navigate"
|
||||
| "control";
|
||||
|
||||
// frontend/lib/utils/buttonActions.ts
|
||||
export type ButtonActionType =
|
||||
| "save"
|
||||
| "cancel"
|
||||
| "delete"
|
||||
| "edit"
|
||||
| "add"
|
||||
| "search"
|
||||
| "reset"
|
||||
| "submit"
|
||||
| "close"
|
||||
| "popup"
|
||||
| "navigate"
|
||||
| "modal"
|
||||
| "newWindow";
|
||||
```
|
||||
|
||||
## 🚨 2. 데이터베이스 스키마와 TypeScript 타입 불일치
|
||||
|
||||
### 2.1 web_type_standards 테이블 불일치
|
||||
|
||||
**문제**: Prisma 스키마와 TypeScript 인터페이스 간 필드명/타입 차이
|
||||
|
||||
#### DB 스키마:
|
||||
|
||||
```sql
|
||||
model web_type_standards {
|
||||
web_type String @id @db.VarChar(50)
|
||||
type_name String @db.VarChar(100)
|
||||
type_name_eng String? @db.VarChar(100)
|
||||
description String?
|
||||
category String? @default("input") @db.VarChar(50)
|
||||
default_config Json? -- JSON 타입
|
||||
validation_rules Json? -- JSON 타입
|
||||
component_name String? @default("TextWidget") @db.VarChar(100)
|
||||
config_panel String? @db.VarChar(100)
|
||||
}
|
||||
```
|
||||
|
||||
#### TypeScript 인터페이스:
|
||||
|
||||
```typescript
|
||||
export interface WebTypeDefinition {
|
||||
id: string; // web_type와 매핑되지 않음
|
||||
name: string; // type_name과 매핑?
|
||||
category: string;
|
||||
description: string;
|
||||
defaultConfig: Record<string, any>; // default_config Json과 타입 불일치
|
||||
validationRules?: Record<string, any>; // validation_rules Json과 타입 불일치
|
||||
isActive: boolean; // DB에는 is_active String 필드
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 ColumnInfo 타입 불일치
|
||||
|
||||
**문제**: 테이블 컬럼 정보 타입이 프론트엔드/백엔드에서 다름
|
||||
|
||||
#### 백엔드 타입:
|
||||
|
||||
```typescript
|
||||
// backend-node/src/types/tableManagement.ts
|
||||
export interface ColumnTypeInfo {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
dbType: string;
|
||||
webType: string; // string 타입
|
||||
inputType?: "direct" | "auto";
|
||||
detailSettings: string; // JSON 문자열
|
||||
isNullable: string; // "Y" | "N" 문자열
|
||||
isPrimaryKey: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
#### 프론트엔드 타입:
|
||||
|
||||
```typescript
|
||||
// frontend/types/screen.ts
|
||||
export interface ColumnInfo {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType: string;
|
||||
webType?: WebType; // WebType union 타입 (불일치!)
|
||||
inputType?: "direct" | "auto";
|
||||
isNullable: string;
|
||||
detailSettings?: string; // optional vs required 차이
|
||||
}
|
||||
```
|
||||
|
||||
## 🚨 3. 컴포넌트 인터페이스 타입 안전성 문제
|
||||
|
||||
### 3.1 ComponentData 타입 캐스팅 문제
|
||||
|
||||
**문제**: 런타임에 타입 안전성이 보장되지 않는 강제 캐스팅
|
||||
|
||||
#### 문제 코드:
|
||||
|
||||
```typescript
|
||||
// frontend/components/screen/RealtimePreview.tsx
|
||||
const widget = component as WidgetComponent; // 위험한 강제 캐스팅
|
||||
|
||||
// frontend/components/screen/InteractiveScreenViewer.tsx
|
||||
component: any; // any 타입으로 타입 안전성 상실
|
||||
```
|
||||
|
||||
### 3.2 DynamicWebTypeRenderer Props 불일치
|
||||
|
||||
**문제**: 동적 렌더링 시 props 타입이 일관되지 않음
|
||||
|
||||
#### 문제 위치:
|
||||
|
||||
```typescript
|
||||
// frontend/lib/registry/DynamicWebTypeRenderer.tsx
|
||||
export interface DynamicComponentProps {
|
||||
webType: string; // WebType이 아닌 string
|
||||
props?: Record<string, any>; // any 타입 사용
|
||||
config?: Record<string, any>; // any 타입 사용
|
||||
onEvent?: (event: string, data: any) => void; // any 타입
|
||||
}
|
||||
|
||||
// 실제 사용 시
|
||||
<DynamicWebTypeRenderer
|
||||
webType={component.webType || "text"} // WebType | undefined 전달
|
||||
config={component.webTypeConfig} // WebTypeConfig 타입 전달
|
||||
props={{
|
||||
component: component, // ComponentData 타입
|
||||
value: formData[component.columnName || component.id] || "",
|
||||
onChange: (value: any) => {...} // any 타입 콜백
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 🚨 4. 제어관리 시스템 타입 문제
|
||||
|
||||
### 4.1 ButtonDataflowConfig 타입 복잡성
|
||||
|
||||
**문제**: 제어관리 설정이 복잡하고 타입 안전성 부족
|
||||
|
||||
#### 현재 타입:
|
||||
|
||||
```typescript
|
||||
export interface ButtonDataflowConfig {
|
||||
controlMode: "simple" | "advanced";
|
||||
selectedDiagramId?: number;
|
||||
selectedRelationshipId?: number;
|
||||
directControl?: {
|
||||
conditions: DataflowCondition[]; // 복잡한 중첩 타입
|
||||
actions: any[]; // any 타입 사용
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 OptimizedButtonDataflowService 타입 문제
|
||||
|
||||
**문제**: 서비스 클래스에서 any 타입 남용으로 타입 안전성 상실
|
||||
|
||||
#### Linter 오류 (57개):
|
||||
|
||||
- `Unexpected any` 경고 26개
|
||||
- `unknown` 타입 오류 2개
|
||||
- 사용되지 않는 변수 경고 29개
|
||||
|
||||
## 🎯 해결방안 및 구현 계획
|
||||
|
||||
## Phase 1: 중앙집중식 타입 정의 통합 (우선순위: 높음)
|
||||
|
||||
### 1.1 통합 타입 파일 생성
|
||||
|
||||
```
|
||||
frontend/types/
|
||||
├── unified-core.ts # 핵심 공통 타입들
|
||||
├── screen-management.ts # 화면관리 전용 타입
|
||||
├── control-management.ts # 제어관리 전용 타입
|
||||
├── table-management.ts # 테이블관리 전용 타입
|
||||
└── index.ts # 모든 타입 re-export
|
||||
```
|
||||
|
||||
### 1.2 WebType 통합 정의
|
||||
|
||||
```typescript
|
||||
// frontend/types/unified-core.ts
|
||||
export type WebType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "decimal"
|
||||
| "date"
|
||||
| "datetime"
|
||||
| "select"
|
||||
| "dropdown"
|
||||
| "radio"
|
||||
| "checkbox"
|
||||
| "boolean"
|
||||
| "textarea"
|
||||
| "code"
|
||||
| "entity"
|
||||
| "file"
|
||||
| "email"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "button";
|
||||
|
||||
// DB에서 동적으로 로드되는 웹타입도 지원
|
||||
export type DynamicWebType = WebType | string;
|
||||
```
|
||||
|
||||
### 1.3 ButtonActionType 통합 정의
|
||||
|
||||
```typescript
|
||||
// frontend/types/unified-core.ts
|
||||
export type ButtonActionType =
|
||||
| "save"
|
||||
| "cancel"
|
||||
| "delete"
|
||||
| "edit"
|
||||
| "add"
|
||||
| "search"
|
||||
| "reset"
|
||||
| "submit"
|
||||
| "close"
|
||||
| "popup"
|
||||
| "modal"
|
||||
| "navigate"
|
||||
| "control";
|
||||
```
|
||||
|
||||
## Phase 2: 데이터베이스 타입 매핑 표준화 (우선순위: 높음)
|
||||
|
||||
### 2.1 Prisma 스키마 기반 타입 생성
|
||||
|
||||
```typescript
|
||||
// frontend/types/database-mappings.ts
|
||||
import { web_type_standards, button_action_standards } from "@prisma/client";
|
||||
|
||||
// Prisma 타입을 프론트엔드 타입으로 변환하는 매퍼
|
||||
export type WebTypeStandard = web_type_standards;
|
||||
|
||||
export interface WebTypeDefinition {
|
||||
webType: string; // web_type 필드
|
||||
typeName: string; // type_name 필드
|
||||
typeNameEng?: string; // type_name_eng 필드
|
||||
description?: string;
|
||||
category: string;
|
||||
defaultConfig: Record<string, any>; // Json 타입 매핑
|
||||
validationRules?: Record<string, any>; // Json 타입 매핑
|
||||
componentName?: string; // component_name 필드
|
||||
configPanel?: string; // config_panel 필드
|
||||
isActive: boolean; // is_active "Y"/"N" → boolean 변환
|
||||
}
|
||||
|
||||
// 변환 함수
|
||||
export const mapWebTypeStandardToDefinition = (
|
||||
standard: WebTypeStandard
|
||||
): WebTypeDefinition => ({
|
||||
webType: standard.web_type,
|
||||
typeName: standard.type_name,
|
||||
typeNameEng: standard.type_name_eng || undefined,
|
||||
description: standard.description || undefined,
|
||||
category: standard.category || "input",
|
||||
defaultConfig: (standard.default_config as any) || {},
|
||||
validationRules: (standard.validation_rules as any) || undefined,
|
||||
componentName: standard.component_name || undefined,
|
||||
configPanel: standard.config_panel || undefined,
|
||||
isActive: standard.is_active === "Y",
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 ColumnInfo 타입 통합
|
||||
|
||||
```typescript
|
||||
// frontend/types/table-management.ts
|
||||
export interface UnifiedColumnInfo {
|
||||
// 공통 필드
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string; // DB 데이터 타입
|
||||
webType: DynamicWebType; // 웹 타입 (동적 지원)
|
||||
|
||||
// 상세 정보
|
||||
inputType: "direct" | "auto";
|
||||
detailSettings?: Record<string, any>; // JSON 파싱된 객체
|
||||
description?: string;
|
||||
isNullable: boolean; // "Y"/"N" → boolean 변환
|
||||
isPrimaryKey: boolean;
|
||||
|
||||
// 표시 옵션
|
||||
isVisible?: boolean;
|
||||
displayOrder?: number;
|
||||
|
||||
// 메타데이터
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
numericScale?: number;
|
||||
defaultValue?: string;
|
||||
|
||||
// 참조 관계
|
||||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 3: 컴포넌트 타입 안전성 강화 (우선순위: 중간)
|
||||
|
||||
### 3.1 ComponentData 타입 가드 구현
|
||||
|
||||
```typescript
|
||||
// frontend/types/screen-management.ts
|
||||
export type ComponentData =
|
||||
| ContainerComponent
|
||||
| WidgetComponent
|
||||
| GroupComponent
|
||||
| DataTableComponent;
|
||||
|
||||
// 타입 가드 함수들
|
||||
export const isWidgetComponent = (
|
||||
component: ComponentData
|
||||
): component is WidgetComponent => {
|
||||
return component.type === "widget";
|
||||
};
|
||||
|
||||
export const isContainerComponent = (
|
||||
component: ComponentData
|
||||
): component is ContainerComponent => {
|
||||
return component.type === "container";
|
||||
};
|
||||
|
||||
// 안전한 타입 캐스팅 유틸리티
|
||||
export const asWidgetComponent = (
|
||||
component: ComponentData
|
||||
): WidgetComponent => {
|
||||
if (!isWidgetComponent(component)) {
|
||||
throw new Error(`Expected WidgetComponent, got ${component.type}`);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 DynamicWebTypeRenderer Props 타입 강화
|
||||
|
||||
```typescript
|
||||
// frontend/lib/registry/types.ts
|
||||
export interface StrictDynamicComponentProps {
|
||||
webType: DynamicWebType;
|
||||
component: ComponentData;
|
||||
config?: WebTypeConfig;
|
||||
value?: unknown;
|
||||
onChange?: (value: unknown) => void;
|
||||
onEvent?: (event: WebTypeEvent) => void;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface WebTypeEvent {
|
||||
type: "change" | "blur" | "focus" | "click";
|
||||
value: unknown;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export type WebTypeConfig = Record<string, unknown>;
|
||||
```
|
||||
|
||||
## Phase 4: 제어관리 시스템 타입 정리 (우선순위: 중간)
|
||||
|
||||
### 4.1 ButtonDataflowConfig 타입 명확화
|
||||
|
||||
```typescript
|
||||
// frontend/types/control-management.ts
|
||||
export interface ButtonDataflowConfig {
|
||||
// 기본 설정
|
||||
controlMode: "simple" | "advanced";
|
||||
|
||||
// 관계도 방식
|
||||
selectedDiagramId?: number;
|
||||
selectedRelationshipId?: number;
|
||||
|
||||
// 직접 설정 방식
|
||||
directControl?: DirectControlConfig;
|
||||
}
|
||||
|
||||
export interface DirectControlConfig {
|
||||
conditions: DataflowCondition[];
|
||||
actions: DataflowAction[];
|
||||
logic?: "AND" | "OR";
|
||||
}
|
||||
|
||||
export interface DataflowCondition {
|
||||
id: string;
|
||||
type: "condition" | "group";
|
||||
field?: string;
|
||||
operator?: ConditionOperator;
|
||||
value?: unknown;
|
||||
dataSource?: "form" | "table-selection" | "both";
|
||||
}
|
||||
|
||||
export interface DataflowAction {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
tableName?: string;
|
||||
operation?: "INSERT" | "UPDATE" | "DELETE" | "SELECT";
|
||||
fields?: ActionField[];
|
||||
conditions?: DataflowCondition[];
|
||||
}
|
||||
|
||||
export type ConditionOperator =
|
||||
| "="
|
||||
| "!="
|
||||
| ">"
|
||||
| "<"
|
||||
| ">="
|
||||
| "<="
|
||||
| "LIKE"
|
||||
| "IN"
|
||||
| "NOT IN";
|
||||
export type ActionType = "database" | "api" | "notification" | "redirect";
|
||||
```
|
||||
|
||||
### 4.2 OptimizedButtonDataflowService 타입 정리
|
||||
|
||||
```typescript
|
||||
// frontend/lib/services/optimizedButtonDataflowService.ts
|
||||
|
||||
// any 타입 제거 및 구체적 타입 정의
|
||||
export interface ExecutionContext {
|
||||
formData: Record<string, unknown>;
|
||||
selectedRows?: unknown[];
|
||||
selectedRowsData?: Record<string, unknown>[];
|
||||
controlDataSource: ControlDataSource;
|
||||
buttonId: string;
|
||||
componentData?: ComponentData;
|
||||
timestamp: string;
|
||||
clickCount?: number;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
canExecuteImmediately: boolean;
|
||||
actions?: DataflowAction[];
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 5: 마이그레이션 및 검증 (우선순위: 낮음)
|
||||
|
||||
### 5.1 점진적 마이그레이션 계획
|
||||
|
||||
1. **Step 1**: 새로운 통합 타입 파일들 생성
|
||||
2. **Step 2**: 기존 파일들에서 새 타입 import로 변경
|
||||
3. **Step 3**: 타입 가드 및 유틸리티 함수 적용
|
||||
4. **Step 4**: any 타입 제거 및 구체적 타입 적용
|
||||
5. **Step 5**: 기존 타입 정의 파일들 제거
|
||||
|
||||
### 5.2 검증 도구 구축
|
||||
|
||||
```typescript
|
||||
// scripts/type-validation.ts
|
||||
// 타입 일관성 검증 스크립트 작성
|
||||
// DB 스키마와 TypeScript 타입 간 일치성 검증
|
||||
// 컴포넌트 Props 타입 검증
|
||||
```
|
||||
|
||||
## 📋 구현 우선순위
|
||||
|
||||
### 🔥 즉시 해결 필요 (Critical)
|
||||
|
||||
1. **WebType 통합** - 가장 많이 사용되는 기본 타입
|
||||
2. **ButtonActionType 통합** - 제어관리 시스템 안정성 확보
|
||||
3. **ColumnInfo 타입 표준화** - 테이블 관리 기능 정상화
|
||||
|
||||
### ⚡ 단기간 해결 (High)
|
||||
|
||||
4. **ComponentData 타입 가드** - 런타임 안전성 확보
|
||||
5. **DB 타입 매핑** - 프론트엔드/백엔드 연동 안정화
|
||||
6. **DynamicWebTypeRenderer Props 정리** - 동적 렌더링 안정성
|
||||
|
||||
### 📅 중장기 해결 (Medium)
|
||||
|
||||
7. **OptimizedButtonDataflowService any 타입 제거** - 코드 품질 향상
|
||||
8. **ButtonDataflowConfig 구조 개선** - 제어관리 시스템 고도화
|
||||
9. **타입 검증 도구 구축** - 지속적인 품질 관리
|
||||
|
||||
## 💡 기대 효과
|
||||
|
||||
### 개발 경험 개선
|
||||
|
||||
- 타입 자동완성 정확도 향상
|
||||
- 컴파일 타임 오류 감소
|
||||
- IDE 지원 기능 활용도 증대
|
||||
|
||||
### 시스템 안정성 향상
|
||||
|
||||
- 런타임 타입 오류 방지
|
||||
- API 연동 안정성 확보
|
||||
- 데이터 일관성 보장
|
||||
|
||||
### 유지보수성 향상
|
||||
|
||||
- 코드 가독성 개선
|
||||
- 리팩토링 안정성 확보
|
||||
- 새 기능 추가 시 사이드 이펙트 최소화
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
이 분석을 바탕으로 다음과 같은 단계로 진행하는 것을 권장합니다:
|
||||
|
||||
1. **우선순위 검토**: 위의 우선순위가 프로젝트 상황에 적합한지 검토
|
||||
2. **Phase 1 착수**: 통합 타입 파일 생성부터 시작
|
||||
3. **점진적 적용**: 한 번에 모든 것을 바꾸지 말고 단계적으로 적용
|
||||
4. **테스트 강화**: 타입 변경 시마다 충분한 테스트 수행
|
||||
|
||||
이 계획에 대한 의견이나 수정사항이 있으시면 말씀해 주세요.
|
||||
Loading…
Reference in New Issue