feat: 화면 관리 및 메뉴 동기화 기능 개선

- 화면 그룹 컨트롤러 기능 확장
- 메뉴 복사 서비스 개선
- 메뉴-화면 동기화 서비스 추가
- 번호 규칙 서비스 개선
- 화면 관리 서비스 확장
- CopyScreenModal 기능 개선
- DataFlowPanel, FieldJoinPanel 수정
This commit is contained in:
DDD1542
2026-01-21 11:53:51 +09:00
parent 40a226ca30
commit ad8b1791bc
15 changed files with 3895 additions and 136 deletions

View File

@@ -33,9 +33,10 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree } from "lucide-react";
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree, Hash, Code, Table, Settings, Database } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { screenApi, updateTabScreenReferences } from "@/lib/api/screen";
import { ScreenGroup, addScreenToGroup, createScreenGroup } from "@/lib/api/screenGroup";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
@@ -135,6 +136,15 @@ export default function CopyScreenModal({
// 그룹 복제 모드: "all" (전체), "folder_only" (폴더만), "screen_only" (화면만)
const [groupCopyMode, setGroupCopyMode] = useState<"all" | "folder_only" | "screen_only">("all");
// 채번규칙 복제 옵션 (체크 시: 복제 → 메뉴 동기화 → 채번규칙 복제 순서로 실행)
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
// 추가 복사 옵션들
const [copyCodeCategory, setCopyCodeCategory] = useState(false); // 코드 카테고리 + 코드 복사
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); // 카테고리 매핑 + 값 복사
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); // 테이블 타입관리 입력타입 설정 복사
const [copyCascadingRelation, setCopyCascadingRelation] = useState(false); // 연쇄관계 설정 복사
// 복사 중 상태
const [isCopying, setIsCopying] = useState(false);
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0, message: "" });
@@ -584,6 +594,7 @@ export default function CopyScreenModal({
screen_id: result.mainScreen.screenId,
screen_role: "MAIN",
display_order: 1,
target_company_code: finalCompanyCode, // 대상 회사 코드 전달
});
console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`);
} catch (groupError) {
@@ -609,7 +620,7 @@ export default function CopyScreenModal({
};
// 이름 변환 헬퍼 함수 (일괄 이름 변경 적용)
const transformName = (originalName: string, isRootGroup: boolean = false): string => {
const transformName = (originalName: string, isRootGroup: boolean = false, sourceCompanyCode?: string): string => {
// 루트 그룹은 사용자가 직접 입력한 이름 사용
if (isRootGroup) {
return newGroupName.trim();
@@ -621,7 +632,12 @@ export default function CopyScreenModal({
return originalName.replace(new RegExp(groupFindText, "g"), groupReplaceText);
}
// 기본: "(복제)" 붙이기
// 다른 회사로 복제하는 경우: 원본 이름 그대로 사용 (중복될 일 없음)
if (sourceCompanyCode && sourceCompanyCode !== targetCompanyCode) {
return originalName;
}
// 같은 회사 내 복제: "(복제)" 붙이기 (중복 방지)
return `${originalName} (복제)`;
};
@@ -633,17 +649,19 @@ export default function CopyScreenModal({
screenCodes: string[], // 미리 생성된 화면 코드 배열
codeIndex: { current: number }, // 현재 사용할 코드 인덱스 (참조로 전달)
stats: { groups: number; screens: number },
totalScreenCount: number // 전체 화면 수 (진행률 표시용)
totalScreenCount: number, // 전체 화면 수 (진행률 표시용)
screenIdMap: { [key: number]: number } // 원본 화면 ID -> 새 화면 ID 매핑
): Promise<void> => {
// 1. 현재 그룹 생성 (원본 display_order 유지)
const timestamp = Date.now();
const randomSuffix = Math.floor(Math.random() * 1000);
const newGroupCode = `${targetCompany}_GROUP_${timestamp}_${randomSuffix}`;
console.log(`📁 그룹 생성: ${sourceGroupData.group_name} (복제)`);
const transformedGroupName = transformName(sourceGroupData.group_name, false, sourceGroupData.company_code);
console.log(`📁 그룹 생성: ${transformedGroupName}`);
const newGroupResponse = await createScreenGroup({
group_name: transformName(sourceGroupData.group_name), // 일괄 이름 변경 적용
group_name: transformedGroupName, // 일괄 이름 변경 적용
group_code: newGroupCode,
parent_group_id: parentGroupId,
target_company_code: targetCompany,
@@ -663,13 +681,29 @@ export default function CopyScreenModal({
const sourceScreensInfo = sourceGroupData.screens || [];
// 화면 정보와 display_order를 함께 매핑
// allScreens에서 못 찾으면 그룹의 screens 정보를 직접 사용 (다른 회사 폴더 복사 시)
const screensWithOrder = sourceScreensInfo.map((s: any) => {
const screenId = typeof s === 'object' ? s.screen_id : s;
const displayOrder = typeof s === 'object' ? s.display_order : 0;
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
const screenData = allScreens.find((sc) => sc.screenId === screenId);
const screenName = typeof s === 'object' ? s.screen_name : '';
const tableName = typeof s === 'object' ? s.table_name : '';
// allScreens에서 먼저 찾고, 없으면 그룹의 screens 정보로 대체
let screenData = allScreens.find((sc) => sc.screenId === screenId);
if (!screenData && screenId && screenName) {
// allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성
screenData = {
screenId: screenId,
screenName: screenName,
screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
tableName: tableName || '',
description: '',
companyCode: sourceGroupData.company_code || '',
} as any;
}
return { screenId, displayOrder, screenRole, screenData };
}).filter(item => item.screenData); // 화면 데이터가 있는 것만
}).filter(item => item.screenData && item.screenId); // screenId가 유효한 것만
// display_order 순으로 정렬
screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
@@ -687,12 +721,13 @@ export default function CopyScreenModal({
message: `화면 복제 중: ${screen.screenName}`
});
console.log(` 📄 화면 복제: ${screen.screenName}${newScreenCode}`);
const transformedScreenName = transformName(screen.screenName, false, sourceGroupData.company_code);
console.log(` 📄 화면 복제: ${screen.screenName}${transformedScreenName}`);
const result = await screenApi.copyScreenWithModals(screen.screenId, {
targetCompanyCode: targetCompany,
mainScreen: {
screenName: transformName(screen.screenName), // 일괄 이름 변경 적용
screenName: transformedScreenName, // 일괄 이름 변경 적용
screenCode: newScreenCode,
description: screen.description || "",
},
@@ -700,14 +735,18 @@ export default function CopyScreenModal({
});
if (result.mainScreen?.screenId) {
// 원본 화면 ID -> 새 화면 ID 매핑 기록
screenIdMap[screen.screenId] = result.mainScreen.screenId;
await addScreenToGroup({
group_id: newGroup.id,
screen_id: result.mainScreen.screenId,
screen_role: screenRole || "MAIN",
display_order: displayOrder, // 원본 정렬순서 유지
target_company_code: targetCompany, // 대상 회사 코드 전달
});
stats.screens++;
console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName}`);
console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName} (${screen.screenId}${result.mainScreen.screenId})`);
}
} catch (screenError) {
console.error(` ❌ 화면 복제 실패 (${screen.screenCode}):`, screenError);
@@ -730,7 +769,8 @@ export default function CopyScreenModal({
screenCodes,
codeIndex,
stats,
totalScreenCount
totalScreenCount,
screenIdMap // screenIdMap 전달
);
}
}
@@ -769,6 +809,7 @@ export default function CopyScreenModal({
const finalCompanyCode = targetCompanyCode || sourceGroup.company_code;
const stats = { groups: 0, screens: 0 };
const screenIdMap: { [key: number]: number } = {}; // 원본 화면 ID -> 새 화면 ID 매핑
console.log("🔄 그룹 복제 시작 (재귀적):", {
sourceGroup: sourceGroup.group_name,
@@ -795,7 +836,7 @@ export default function CopyScreenModal({
// 일괄 이름 변경이 활성화된 경우 원본 이름에 변환 적용
const rootGroupName = useGroupBulkRename && groupFindText
? transformName(sourceGroup.group_name)
? transformName(sourceGroup.group_name, false, sourceGroup.company_code)
: newGroupName.trim();
const newGroupResponse = await createScreenGroup({
@@ -818,14 +859,41 @@ export default function CopyScreenModal({
if (groupCopyMode !== "folder_only") {
const sourceScreensInfo = sourceGroup.screens || [];
// 화면 정보와 display_order를 함께 매핑
const screensWithOrder = sourceScreensInfo.map((s: any) => {
const screenId = typeof s === 'object' ? s.screen_id : s;
const displayOrder = typeof s === 'object' ? s.display_order : 0;
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
const screenData = allScreens.find((sc) => sc.screenId === screenId);
return { screenId, displayOrder, screenRole, screenData };
}).filter(item => item.screenData);
// 화면 정보와 display_order를 함께 매핑
// allScreens에서 못 찾으면 그룹의 screens 정보를 직접 사용 (다른 회사 폴더 복사 시)
console.log(`🔍 루트 그룹 화면 매핑 시작: ${sourceScreensInfo.length}개 화면, allScreens: ${allScreens.length}`);
const screensWithOrder = sourceScreensInfo.map((s: any) => {
const screenId = typeof s === 'object' ? s.screen_id : s;
const displayOrder = typeof s === 'object' ? s.display_order : 0;
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
const screenName = typeof s === 'object' ? s.screen_name : '';
const tableName = typeof s === 'object' ? s.table_name : '';
// allScreens에서 먼저 찾고, 없으면 그룹의 screens 정보로 대체
let screenData = allScreens.find((sc) => sc.screenId === screenId);
const foundInAllScreens = !!screenData;
if (!screenData && screenId && screenName) {
// allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성
console.log(` ⚠️ allScreens에서 못 찾음, 그룹 정보 사용: ${screenId} - ${screenName}`);
screenData = {
screenId: screenId,
screenName: screenName,
screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
tableName: tableName || '',
description: '',
companyCode: sourceGroup.company_code || '',
} as any;
} else if (screenData) {
console.log(` ✅ allScreens에서 찾음: ${screenId} - ${screenData.screenName}`);
} else {
console.log(` ❌ 화면 정보 없음: screenId=${screenId}, screenName=${screenName}`);
}
return { screenId, displayOrder, screenRole, screenData };
}).filter(item => item.screenData && item.screenId); // screenId가 유효한 것만
console.log(`🔍 매핑 완료: ${screensWithOrder.length}개 화면 복사 예정`);
screensWithOrder.forEach(item => console.log(` - ${item.screenId}: ${item.screenData?.screenName}`));
// display_order 순으로 정렬
screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
@@ -843,12 +911,13 @@ export default function CopyScreenModal({
message: `화면 복제 중: ${screen.screenName}`
});
console.log(`📄 화면 복제: ${screen.screenName}${newScreenCode}`);
const transformedScreenName = transformName(screen.screenName, false, sourceGroup.company_code);
console.log(`📄 화면 복제: ${screen.screenName}${transformedScreenName}`);
const result = await screenApi.copyScreenWithModals(screen.screenId, {
targetCompanyCode: finalCompanyCode,
mainScreen: {
screenName: transformName(screen.screenName), // 일괄 이름 변경 적용
screenName: transformedScreenName, // 일괄 이름 변경 적용
screenCode: newScreenCode,
description: screen.description || "",
},
@@ -856,14 +925,18 @@ export default function CopyScreenModal({
});
if (result.mainScreen?.screenId) {
// 원본 화면 ID -> 새 화면 ID 매핑 기록
screenIdMap[screen.screenId] = result.mainScreen.screenId;
await addScreenToGroup({
group_id: newRootGroup.id,
screen_id: result.mainScreen.screenId,
screen_role: screenRole || "MAIN",
display_order: displayOrder, // 원본 정렬순서 유지
target_company_code: finalCompanyCode, // 대상 회사 코드 전달
});
stats.screens++;
console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName}`);
console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName} (${screen.screenId}${result.mainScreen.screenId})`);
}
} catch (screenError) {
console.error(`화면 복제 실패 (${screen.screenCode}):`, screenError);
@@ -886,11 +959,180 @@ export default function CopyScreenModal({
screenCodes,
codeIndex,
stats,
totalScreenCount
totalScreenCount,
screenIdMap // screenIdMap 전달
);
}
}
// 6. 탭 컴포넌트의 screenId 참조 일괄 업데이트
console.log("🔍 screenIdMap 상태:", screenIdMap, "키 개수:", Object.keys(screenIdMap).length);
if (Object.keys(screenIdMap).length > 0) {
console.log("🔗 탭 screenId 참조 업데이트 중...", screenIdMap);
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "탭 참조 업데이트 중..." });
const targetScreenIds = Object.values(screenIdMap);
try {
const updateResult = await updateTabScreenReferences(targetScreenIds, screenIdMap);
console.log(`✅ 탭 screenId 참조 업데이트 완료: ${updateResult.updated}개 레이아웃`);
} catch (tabUpdateError) {
console.warn("탭 screenId 참조 업데이트 실패 (무시):", tabUpdateError);
}
}
// 7. 채번규칙 복제 옵션이 선택된 경우 (복제 → 메뉴 동기화 → 채번규칙 복제)
if (copyNumberingRules) {
try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "메뉴 동기화 중..." });
console.log("📋 메뉴 동기화 시작 (채번규칙 복제 준비)...");
// 7-1. 메뉴 동기화 (화면 그룹 → 메뉴)
const syncResponse = await apiClient.post("/screen-groups/sync/screen-to-menu", {
targetCompanyCode: finalCompanyCode,
});
if (syncResponse.data?.success) {
console.log("✅ 메뉴 동기화 완료:", syncResponse.data.data);
// 7-2. 채번규칙 복제
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "채번규칙 복제 중..." });
console.log("📋 채번규칙 복제 시작...");
const numberingResponse = await apiClient.post("/numbering-rules/copy-for-company", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
});
if (numberingResponse.data?.success) {
console.log("✅ 채번규칙 복제 완료:", numberingResponse.data.data);
toast.success(`채번규칙 ${numberingResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`);
} else {
console.warn("채번규칙 복제 실패:", numberingResponse.data?.error);
toast.warning("채번규칙 복제에 실패했습니다. 수동으로 복제해주세요.");
}
// 7-3. 화면-메뉴 할당 복제 (screen_menu_assignments)
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "화면-메뉴 할당 복제 중..." });
console.log("📋 화면-메뉴 할당 복제 시작...");
const menuAssignResponse = await apiClient.post("/screen-management/copy-menu-assignments", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
screenIdMap,
});
if (menuAssignResponse.data?.success) {
console.log("✅ 화면-메뉴 할당 복제 완료:", menuAssignResponse.data.data);
toast.success(`화면-메뉴 할당 ${menuAssignResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`);
} else {
console.warn("화면-메뉴 할당 복제 실패:", menuAssignResponse.data?.error);
}
} else {
console.warn("메뉴 동기화 실패:", syncResponse.data?.error);
toast.warning("메뉴 동기화에 실패했습니다. 채번규칙이 복제되지 않았습니다.");
}
} catch (numberingError) {
console.error("채번규칙 복제 중 오류:", numberingError);
toast.warning("채번규칙 복제 중 오류가 발생했습니다.");
}
}
// 8. 코드 카테고리 + 코드 복제
if (copyCodeCategory) {
try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "코드 카테고리/코드 복제 중..." });
console.log("📋 코드 카테고리/코드 복제 시작...");
const response = await apiClient.post("/screen-management/copy-code-category", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
});
if (response.data?.success) {
console.log("✅ 코드 카테고리/코드 복제 완료:", response.data.data);
toast.success(`코드 카테고리 ${response.data.data?.copiedCategories || 0}개, 코드 ${response.data.data?.copiedCodes || 0}개가 복제되었습니다.`);
} else {
console.warn("코드 카테고리/코드 복제 실패:", response.data?.error);
toast.warning("코드 카테고리/코드 복제에 실패했습니다.");
}
} catch (error) {
console.error("코드 카테고리/코드 복제 중 오류:", error);
toast.warning("코드 카테고리/코드 복제 중 오류가 발생했습니다.");
}
}
// 9. 카테고리 매핑 + 값 복제
if (copyCategoryMapping) {
try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "카테고리 매핑/값 복제 중..." });
console.log("📋 카테고리 매핑/값 복제 시작...");
const response = await apiClient.post("/screen-management/copy-category-mapping", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
});
if (response.data?.success) {
console.log("✅ 카테고리 매핑/값 복제 완료:", response.data.data);
toast.success(`카테고리 매핑 ${response.data.data?.copiedMappings || 0}개, 값 ${response.data.data?.copiedValues || 0}개가 복제되었습니다.`);
} else {
console.warn("카테고리 매핑/값 복제 실패:", response.data?.error);
toast.warning("카테고리 매핑/값 복제에 실패했습니다.");
}
} catch (error) {
console.error("카테고리 매핑/값 복제 중 오류:", error);
toast.warning("카테고리 매핑/값 복제 중 오류가 발생했습니다.");
}
}
// 10. 테이블 타입관리 입력타입 설정 복제
if (copyTableTypeColumns) {
try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "테이블 타입 컬럼 복제 중..." });
console.log("📋 테이블 타입 컬럼 복제 시작...");
const response = await apiClient.post("/screen-management/copy-table-type-columns", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
});
if (response.data?.success) {
console.log("✅ 테이블 타입 컬럼 복제 완료:", response.data.data);
toast.success(`테이블 타입 컬럼 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`);
} else {
console.warn("테이블 타입 컬럼 복제 실패:", response.data?.error);
toast.warning("테이블 타입 컬럼 복제에 실패했습니다.");
}
} catch (error) {
console.error("테이블 타입 컬럼 복제 중 오류:", error);
toast.warning("테이블 타입 컬럼 복제 중 오류가 발생했습니다.");
}
}
// 11. 연쇄관계 설정 복제
if (copyCascadingRelation) {
try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "연쇄관계 설정 복제 중..." });
console.log("📋 연쇄관계 설정 복제 시작...");
const response = await apiClient.post("/screen-management/copy-cascading-relation", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
});
if (response.data?.success) {
console.log("✅ 연쇄관계 설정 복제 완료:", response.data.data);
toast.success(`연쇄관계 설정 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`);
} else {
console.warn("연쇄관계 설정 복제 실패:", response.data?.error);
toast.warning("연쇄관계 설정 복제에 실패했습니다.");
}
} catch (error) {
console.error("연쇄관계 설정 복제 중 오류:", error);
toast.warning("연쇄관계 설정 복제 중 오류가 발생했습니다.");
}
}
toast.success(
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
);
@@ -1045,6 +1287,89 @@ export default function CopyScreenModal({
</p>
</div>
{/* 추가 복사 옵션 (선택사항) */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm font-medium"> ():</Label>
{/* 코드 카테고리 + 코드 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyCodeCategory"
checked={copyCodeCategory}
onCheckedChange={(checked) => setCopyCodeCategory(checked === true)}
/>
<Label htmlFor="copyCodeCategory" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Code className="h-4 w-4 text-muted-foreground" />
+
</Label>
</div>
{/* 채번규칙 복제 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyNumberingRules"
checked={copyNumberingRules}
onCheckedChange={(checked) => setCopyNumberingRules(checked === true)}
/>
<Label htmlFor="copyNumberingRules" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Hash className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
{/* 카테고리 매핑 + 값 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyCategoryMapping"
checked={copyCategoryMapping}
onCheckedChange={(checked) => setCopyCategoryMapping(checked === true)}
/>
<Label htmlFor="copyCategoryMapping" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Table className="h-4 w-4 text-muted-foreground" />
+
</Label>
</div>
{/* 테이블 타입관리 입력타입 설정 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyTableTypeColumns"
checked={copyTableTypeColumns}
onCheckedChange={(checked) => setCopyTableTypeColumns(checked === true)}
/>
<Label htmlFor="copyTableTypeColumns" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
{/* 연쇄관계 설정 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyCascadingRelation"
checked={copyCascadingRelation}
onCheckedChange={(checked) => setCopyCascadingRelation(checked === true)}
/>
<Label htmlFor="copyCascadingRelation" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
</div>
{/* 기본 복사 항목 안내 */}
<div className="p-3 bg-muted/50 rounded-lg">
<p className="text-xs font-medium mb-1"> :</p>
<ul className="text-[10px] sm:text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li> ( )</li>
<li> + (, )</li>
<li> (, )</li>
</ul>
<p className="text-[10px] text-muted-foreground mt-2 italic">
* , , .
</p>
</div>
{/* 새 그룹명 + 정렬 순서 */}
<div className="flex gap-3">
<div className="flex-1">

View File

@@ -462,3 +462,5 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF

View File

@@ -414,3 +414,5 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel