[agent-pipeline] pipe-20260311151253-nyk7 round-1
|
|
@ -0,0 +1,98 @@
|
||||||
|
# 화면 디자이너 E2E 테스트 접근 가이드
|
||||||
|
|
||||||
|
## 화면 디자이너 접근 방법 (Playwright)
|
||||||
|
|
||||||
|
화면 디자이너는 SPA 탭 기반 시스템이라 URL 직접 접근이 안 된다.
|
||||||
|
다음 3단계를 반드시 따라야 한다.
|
||||||
|
|
||||||
|
### 1단계: 로그인
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await page.goto('http://localhost:9771/login');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.getByPlaceholder('사용자 ID를 입력하세요').fill('wace');
|
||||||
|
await page.getByPlaceholder('비밀번호를 입력하세요').fill('qlalfqjsgh11');
|
||||||
|
await page.getByRole('button', { name: '로그인' }).click();
|
||||||
|
await page.waitForTimeout(8000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2단계: sessionStorage 탭 상태 주입 + openDesigner 쿼리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await page.evaluate(() => {
|
||||||
|
sessionStorage.setItem('erp-tab-store', JSON.stringify({
|
||||||
|
state: {
|
||||||
|
tabs: [{
|
||||||
|
id: 'tab-screenmng',
|
||||||
|
title: '화면 관리',
|
||||||
|
path: '/admin/screenMng/screenMngList',
|
||||||
|
isActive: true,
|
||||||
|
isPinned: false
|
||||||
|
}],
|
||||||
|
activeTabId: 'tab-screenmng'
|
||||||
|
},
|
||||||
|
version: 0
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// openDesigner 쿼리 파라미터로 화면 디자이너 자동 열기
|
||||||
|
await page.goto('http://localhost:9771/admin/screenMng/screenMngList?openDesigner=' + screenId);
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: 컴포넌트 클릭 + 설정 패널 확인
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 패널 버튼 클릭 (설정 패널 열기)
|
||||||
|
const panelBtn = page.locator('button:has-text("패널")');
|
||||||
|
if (await panelBtn.count() > 0) {
|
||||||
|
await panelBtn.first().click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 탭 확인
|
||||||
|
const editTab = page.locator('button:has-text("편집")');
|
||||||
|
// editTab.count() > 0 이면 설정 패널 열림 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
## 화면 ID 찾기 (API)
|
||||||
|
|
||||||
|
특정 컴포넌트를 포함한 화면을 API로 검색:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const screenId = await page.evaluate(async () => {
|
||||||
|
const token = localStorage.getItem('authToken') || '';
|
||||||
|
const h = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
|
|
||||||
|
const resp = await fetch('http://localhost:8080/api/screen-management/screens?page=1&size=50', { headers: h });
|
||||||
|
const data = await resp.json();
|
||||||
|
const items = data.data || [];
|
||||||
|
|
||||||
|
for (const s of items) {
|
||||||
|
try {
|
||||||
|
const lr = await fetch('http://localhost:8080/api/screen-management/screens/' + s.screenId + '/layout-v2', { headers: h });
|
||||||
|
const ld = await lr.json();
|
||||||
|
const raw = JSON.stringify(ld);
|
||||||
|
// 원하는 컴포넌트 타입 검색
|
||||||
|
if (raw.includes('v2-select')) return s.screenId;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return items[0]?.screenId || null;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검증 포인트
|
||||||
|
|
||||||
|
| 확인 항목 | Locator | 기대값 |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| 디자이너 열림 | `button:has-text("패널")` | count > 0 |
|
||||||
|
| 편집 탭 | `button:has-text("편집")` | count > 0 |
|
||||||
|
| 카드 선택 | `text=이 필드는 어떤 데이터를 선택하나요?` | visible |
|
||||||
|
| 고급 설정 | `text=고급 설정` | visible |
|
||||||
|
| JS 에러 없음 | `page.on('pageerror')` | 0건 |
|
||||||
|
|
||||||
|
## 테스트 계정
|
||||||
|
|
||||||
|
- ID: `wace`
|
||||||
|
- PW: `qlalfqjsgh11`
|
||||||
|
- 권한: SUPER_ADMIN (최고 관리자)
|
||||||
|
|
@ -0,0 +1,691 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
||||||
|
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||||
|
|
||||||
|
interface ButtonConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
// 🔧 항상 최신 component에서 직접 참조
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const currentAction = component.componentConfig?.action || {}; // 🔧 최신 action 참조
|
||||||
|
|
||||||
|
// 로컬 상태 관리 (실시간 입력 반영)
|
||||||
|
const [localInputs, setLocalInputs] = useState({
|
||||||
|
text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용
|
||||||
|
modalTitle: config.action?.modalTitle || "",
|
||||||
|
editModalTitle: config.action?.editModalTitle || "",
|
||||||
|
editModalDescription: config.action?.editModalDescription || "",
|
||||||
|
targetUrl: config.action?.targetUrl || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [localSelects, setLocalSelects] = useState({
|
||||||
|
variant: config.variant || "default",
|
||||||
|
size: config.size || "md", // 🔧 기본값을 "md"로 변경
|
||||||
|
actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined)
|
||||||
|
modalSize: config.action?.modalSize || "md",
|
||||||
|
editMode: config.action?.editMode || "modal",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
||||||
|
const [screensLoading, setScreensLoading] = useState(false);
|
||||||
|
const [modalScreenOpen, setModalScreenOpen] = useState(false);
|
||||||
|
const [navScreenOpen, setNavScreenOpen] = useState(false);
|
||||||
|
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
||||||
|
const [navSearchTerm, setNavSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔄 ButtonConfigPanel useEffect 실행:", {
|
||||||
|
componentId: component.id,
|
||||||
|
"config.action?.type": config.action?.type,
|
||||||
|
"localSelects.actionType (before)": localSelects.actionType,
|
||||||
|
fullAction: config.action,
|
||||||
|
"component.componentConfig.action": component.componentConfig?.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalInputs({
|
||||||
|
text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용
|
||||||
|
modalTitle: config.action?.modalTitle || "",
|
||||||
|
editModalTitle: config.action?.editModalTitle || "",
|
||||||
|
editModalDescription: config.action?.editModalDescription || "",
|
||||||
|
targetUrl: config.action?.targetUrl || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalSelects((prev) => {
|
||||||
|
const newSelects = {
|
||||||
|
variant: config.variant || "default",
|
||||||
|
size: config.size || "md", // 🔧 기본값을 "md"로 변경
|
||||||
|
actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined)
|
||||||
|
modalSize: config.action?.modalSize || "md",
|
||||||
|
editMode: config.action?.editMode || "modal",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📝 setLocalSelects 호출:", {
|
||||||
|
"prev.actionType": prev.actionType,
|
||||||
|
"new.actionType": newSelects.actionType,
|
||||||
|
"config.action?.type": config.action?.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newSelects;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
component.id, // 🔧 컴포넌트 ID (다른 컴포넌트로 전환 시)
|
||||||
|
component.componentConfig?.action?.type, // 🔧 액션 타입 (액션 변경 시 즉시 반영)
|
||||||
|
component.componentConfig?.text, // 🔧 버튼 텍스트
|
||||||
|
component.componentConfig?.variant, // 🔧 버튼 스타일
|
||||||
|
component.componentConfig?.size, // 🔧 버튼 크기
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 화면 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchScreens = async () => {
|
||||||
|
try {
|
||||||
|
setScreensLoading(true);
|
||||||
|
const response = await apiClient.get("/screen-management/screens");
|
||||||
|
|
||||||
|
if (response.data.success && Array.isArray(response.data.data)) {
|
||||||
|
const screenList = response.data.data.map((screen: any) => ({
|
||||||
|
id: screen.screenId,
|
||||||
|
name: screen.screenName,
|
||||||
|
description: screen.description,
|
||||||
|
}));
|
||||||
|
setScreens(screenList);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("❌ 화면 목록 로딩 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setScreensLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchScreens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 검색 필터링 함수
|
||||||
|
const filterScreens = (searchTerm: string) => {
|
||||||
|
if (!searchTerm.trim()) return screens;
|
||||||
|
return screens.filter(
|
||||||
|
(screen) =>
|
||||||
|
screen.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
|
||||||
|
component,
|
||||||
|
config,
|
||||||
|
action: config.action,
|
||||||
|
actionType: config.action?.type,
|
||||||
|
screensCount: screens.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-text">버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
id="button-text"
|
||||||
|
value={localInputs.text}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, text: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.text", newValue);
|
||||||
|
}}
|
||||||
|
placeholder="버튼 텍스트를 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-variant">버튼 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.variant}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, variant: value }));
|
||||||
|
onUpdateProperty("componentConfig.variant", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="버튼 스타일 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="primary">기본 (Primary)</SelectItem>
|
||||||
|
<SelectItem value="secondary">보조 (Secondary)</SelectItem>
|
||||||
|
<SelectItem value="danger">위험 (Danger)</SelectItem>
|
||||||
|
<SelectItem value="success">성공 (Success)</SelectItem>
|
||||||
|
<SelectItem value="outline">외곽선 (Outline)</SelectItem>
|
||||||
|
<SelectItem value="ghost">고스트 (Ghost)</SelectItem>
|
||||||
|
<SelectItem value="link">링크 (Link)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-size">버튼 글씨 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.size}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, size: value }));
|
||||||
|
onUpdateProperty("componentConfig.size", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="버튼 글씨 크기 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||||
|
<SelectItem value="md">기본 (Default)</SelectItem>
|
||||||
|
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-action">버튼 액션</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.actionType || undefined}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
console.log("🔵 버튼 액션 변경 시작:", {
|
||||||
|
oldValue: localSelects.actionType,
|
||||||
|
newValue: value,
|
||||||
|
componentId: component.id,
|
||||||
|
"현재 component.componentConfig.action": component.componentConfig?.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setLocalSelects((prev) => {
|
||||||
|
console.log("📝 setLocalSelects (액션 변경):", {
|
||||||
|
"prev.actionType": prev.actionType,
|
||||||
|
"new.actionType": value,
|
||||||
|
});
|
||||||
|
return { ...prev, actionType: value };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔧 개별 속성만 업데이트
|
||||||
|
onUpdateProperty("componentConfig.action.type", value);
|
||||||
|
|
||||||
|
// 액션에 따른 라벨 색상 자동 설정 (별도 호출)
|
||||||
|
if (value === "delete") {
|
||||||
|
onUpdateProperty("style.labelColor", "#ef4444");
|
||||||
|
} else {
|
||||||
|
onUpdateProperty("style.labelColor", "#212121");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 버튼 액션 변경 완료");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="버튼 액션 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="save">저장</SelectItem>
|
||||||
|
<SelectItem value="cancel">취소</SelectItem>
|
||||||
|
<SelectItem value="delete">삭제</SelectItem>
|
||||||
|
<SelectItem value="edit">수정</SelectItem>
|
||||||
|
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||||
|
<SelectItem value="add">추가</SelectItem>
|
||||||
|
<SelectItem value="search">검색</SelectItem>
|
||||||
|
<SelectItem value="reset">초기화</SelectItem>
|
||||||
|
<SelectItem value="submit">제출</SelectItem>
|
||||||
|
<SelectItem value="close">닫기</SelectItem>
|
||||||
|
<SelectItem value="modal">모달 열기</SelectItem>
|
||||||
|
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||||
|
<SelectItem value="control">제어 (조건 체크만)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 열기 액션 설정 */}
|
||||||
|
{localSelects.actionType === "modal" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-muted p-4">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">모달 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="modal-title">모달 제목</Label>
|
||||||
|
<Input
|
||||||
|
id="modal-title"
|
||||||
|
placeholder="모달 제목을 입력하세요"
|
||||||
|
value={localInputs.modalTitle}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.action.modalTitle", newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="modal-size">모달 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.modalSize}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
|
||||||
|
onUpdateProperty("componentConfig.action.modalSize", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="모달 크기 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||||
|
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||||
|
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||||
|
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target-screen-modal">대상 화면 선택</Label>
|
||||||
|
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={modalScreenOpen}
|
||||||
|
className="h-10 w-full justify-between"
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
{config.action?.targetScreenId
|
||||||
|
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||||
|
"화면을 선택하세요..."
|
||||||
|
: "화면을 선택하세요..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={modalSearchTerm}
|
||||||
|
onChange={(e) => setModalSearchTerm(e.target.value)}
|
||||||
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-auto">
|
||||||
|
{(() => {
|
||||||
|
const filteredScreens = filterScreens(modalSearchTerm);
|
||||||
|
if (screensLoading) {
|
||||||
|
return <div className="p-3 text-sm text-muted-foreground">화면 목록을 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
if (filteredScreens.length === 0) {
|
||||||
|
return <div className="p-3 text-sm text-muted-foreground">검색 결과가 없습니다.</div>;
|
||||||
|
}
|
||||||
|
return filteredScreens.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`modal-screen-${screen.id}-${index}`}
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
|
setModalScreenOpen(false);
|
||||||
|
setModalSearchTerm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.name}</span>
|
||||||
|
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수정 액션 설정 */}
|
||||||
|
{localSelects.actionType === "edit" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-emerald-50 p-4">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">수정 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-screen">수정 폼 화면 선택</Label>
|
||||||
|
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={modalScreenOpen}
|
||||||
|
className="h-10 w-full justify-between"
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
{config.action?.targetScreenId
|
||||||
|
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||||
|
"수정 폼 화면을 선택하세요..."
|
||||||
|
: "수정 폼 화면을 선택하세요..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={modalSearchTerm}
|
||||||
|
onChange={(e) => setModalSearchTerm(e.target.value)}
|
||||||
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-auto">
|
||||||
|
{(() => {
|
||||||
|
const filteredScreens = filterScreens(modalSearchTerm);
|
||||||
|
if (screensLoading) {
|
||||||
|
return <div className="p-3 text-sm text-muted-foreground">화면 목록을 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
if (filteredScreens.length === 0) {
|
||||||
|
return <div className="p-3 text-sm text-muted-foreground">검색 결과가 없습니다.</div>;
|
||||||
|
}
|
||||||
|
return filteredScreens.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`edit-screen-${screen.id}-${index}`}
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
|
setModalScreenOpen(false);
|
||||||
|
setModalSearchTerm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{screen.name}</span>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 복사 액션 설정 */}
|
||||||
|
{localSelects.actionType === "copy" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-primary/10 p-4">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">복사 설정 (품목코드 자동 초기화)</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="copy-screen">복사 폼 화면 선택</Label>
|
||||||
|
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={modalScreenOpen}
|
||||||
|
className="h-10 w-full justify-between"
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
{config.action?.targetScreenId
|
||||||
|
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||||
|
"복사 폼 화면을 선택하세요..."
|
||||||
|
: "복사 폼 화면을 선택하세요..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={modalSearchTerm}
|
||||||
|
onChange={(e) => setModalSearchTerm(e.target.value)}
|
||||||
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-auto">
|
||||||
|
{(() => {
|
||||||
|
const filteredScreens = filterScreens(modalSearchTerm);
|
||||||
|
if (screensLoading) {
|
||||||
|
return <div className="p-3 text-sm text-muted-foreground">화면 목록을 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
if (filteredScreens.length === 0) {
|
||||||
|
return <div className="p-3 text-sm text-muted-foreground">검색 결과가 없습니다.</div>;
|
||||||
|
}
|
||||||
|
return filteredScreens.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`edit-screen-${screen.id}-${index}`}
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
|
setModalScreenOpen(false);
|
||||||
|
setModalSearchTerm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.name}</span>
|
||||||
|
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
선택된 데이터가 복사되며, 품목코드는 자동으로 초기화됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="copy-mode">복사 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.editMode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, editMode: value }));
|
||||||
|
onUpdateProperty("componentConfig.action.editMode", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="수정 모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="modal">모달로 열기</SelectItem>
|
||||||
|
<SelectItem value="navigate">새 페이지로 이동</SelectItem>
|
||||||
|
<SelectItem value="inline">현재 화면에서 수정</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localSelects.editMode === "modal" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-modal-title">모달 제목</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-modal-title"
|
||||||
|
placeholder="모달 제목을 입력하세요 (예: 데이터 수정)"
|
||||||
|
value={localInputs.editModalTitle}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
|
||||||
|
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">비워두면 기본 제목이 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-modal-description">모달 설명</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-modal-description"
|
||||||
|
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 수정합니다)"
|
||||||
|
value={localInputs.editModalDescription}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
|
||||||
|
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">비워두면 설명이 표시되지 않습니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-modal-size">모달 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.modalSize}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
|
||||||
|
onUpdateProperty("componentConfig.action.modalSize", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="모달 크기 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||||
|
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||||
|
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||||
|
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||||
|
<SelectItem value="full">전체 화면 (Full)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지 이동 액션 설정 */}
|
||||||
|
{localSelects.actionType === "navigate" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-muted p-4">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">페이지 이동 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target-screen-nav">이동할 화면 선택</Label>
|
||||||
|
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={navScreenOpen}
|
||||||
|
className="h-10 w-full justify-between"
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
{config.action?.targetScreenId
|
||||||
|
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||||
|
"화면을 선택하세요..."
|
||||||
|
: "화면을 선택하세요..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={navSearchTerm}
|
||||||
|
onChange={(e) => setNavSearchTerm(e.target.value)}
|
||||||
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-auto">
|
||||||
|
{(() => {
|
||||||
|
const filteredScreens = filterScreens(navSearchTerm);
|
||||||
|
if (screensLoading) {
|
||||||
|
return <div className="p-3 text-sm text-muted-foreground">화면 목록을 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
if (filteredScreens.length === 0) {
|
||||||
|
return <div className="p-3 text-sm text-muted-foreground">검색 결과가 없습니다.</div>;
|
||||||
|
}
|
||||||
|
return filteredScreens.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`navigate-screen-${screen.id}-${index}`}
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
|
setNavScreenOpen(false);
|
||||||
|
setNavSearchTerm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.name}</span>
|
||||||
|
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target-url">또는 직접 URL 입력 (고급)</Label>
|
||||||
|
<Input
|
||||||
|
id="target-url"
|
||||||
|
placeholder="예: /admin/users 또는 https://example.com"
|
||||||
|
value={localInputs.targetUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.action.targetUrl", newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">URL을 입력하면 화면 선택보다 우선 적용됩니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🔥 NEW: 제어관리 기능 섹션 */}
|
||||||
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">🔧 고급 기능</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">버튼 액션과 함께 실행될 추가 기능을 설정합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2SectionCard 설정 패널
|
||||||
|
* 토스식 단계별 UX: 패딩 카드 선택 -> 배경/테두리 설정 -> 고급 설정(접힘)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
ChevronDown,
|
||||||
|
Square,
|
||||||
|
Minus,
|
||||||
|
SquareDashed,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// ─── 내부 여백 카드 정의 ───
|
||||||
|
const PADDING_CARDS = [
|
||||||
|
{ value: "none", label: "없음", size: "0px" },
|
||||||
|
{ value: "sm", label: "작게", size: "12px" },
|
||||||
|
{ value: "md", label: "중간", size: "24px" },
|
||||||
|
{ value: "lg", label: "크게", size: "32px" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── 배경색 카드 정의 ───
|
||||||
|
const BG_CARDS = [
|
||||||
|
{ value: "default", label: "카드", description: "기본 카드 배경" },
|
||||||
|
{ value: "muted", label: "회색", description: "연한 회색 배경" },
|
||||||
|
{ value: "transparent", label: "투명", description: "배경 없음" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── 테두리 스타일 카드 정의 ───
|
||||||
|
const BORDER_CARDS = [
|
||||||
|
{ value: "solid", label: "실선", icon: Minus },
|
||||||
|
{ value: "dashed", label: "점선", icon: SquareDashed },
|
||||||
|
{ value: "none", label: "없음", icon: Square },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface V2SectionCardConfigPanelProps {
|
||||||
|
config: Record<string, any>;
|
||||||
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const V2SectionCardConfigPanel: React.FC<
|
||||||
|
V2SectionCardConfigPanelProps
|
||||||
|
> = ({ config, onChange }) => {
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
|
||||||
|
const updateConfig = (field: string, value: any) => {
|
||||||
|
const newConfig = { ...config, [field]: value };
|
||||||
|
onChange(newConfig);
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("componentConfigChanged", {
|
||||||
|
detail: { config: newConfig },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ─── 1단계: 헤더 설정 ─── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">헤더 표시</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
섹션 상단에 제목과 설명을 표시해요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.showHeader !== false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("showHeader", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.showHeader !== false && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs text-muted-foreground">제목</span>
|
||||||
|
<Input
|
||||||
|
value={config.title || ""}
|
||||||
|
onChange={(e) => updateConfig("title", e.target.value)}
|
||||||
|
placeholder="섹션 제목 입력"
|
||||||
|
className="h-7 w-[180px] text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">설명 (선택)</span>
|
||||||
|
<Textarea
|
||||||
|
value={config.description || ""}
|
||||||
|
onChange={(e) => updateConfig("description", e.target.value)}
|
||||||
|
placeholder="섹션에 대한 간단한 설명"
|
||||||
|
className="mt-1.5 text-xs resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── 2단계: 내부 여백 카드 선택 ─── */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">내부 여백</p>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{PADDING_CARDS.map((card) => {
|
||||||
|
const isSelected = (config.padding || "md") === card.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={card.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateConfig("padding", card.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center rounded-lg border p-2.5 text-center transition-all min-h-[60px]",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium">{card.label}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{card.size}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── 3단계: 외관 설정 ─── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium">외관</p>
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||||
|
{/* 배경색 */}
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">배경색</span>
|
||||||
|
<div className="mt-1.5 grid grid-cols-3 gap-2">
|
||||||
|
{BG_CARDS.map((card) => {
|
||||||
|
const isSelected =
|
||||||
|
(config.backgroundColor || "default") === card.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={card.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
updateConfig("backgroundColor", card.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center rounded-md border p-2 text-center transition-all",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium">{card.label}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{card.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테두리 스타일 */}
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">테두리</span>
|
||||||
|
<div className="mt-1.5 grid grid-cols-3 gap-2">
|
||||||
|
{BORDER_CARDS.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
const isSelected =
|
||||||
|
(config.borderStyle || "solid") === card.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={card.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateConfig("borderStyle", card.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center rounded-md border p-2 text-center transition-all gap-1",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium">{card.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
|
||||||
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">고급 설정</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||||
|
advancedOpen && "rotate-180"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||||
|
{/* 접기/펼치기 */}
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">접기/펼치기</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
사용자가 섹션을 접거나 펼칠 수 있어요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.collapsible || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig("collapsible", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.collapsible && (
|
||||||
|
<div className="ml-4 border-l-2 border-primary/20 pl-3">
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">기본으로 펼치기</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
화면 로드 시 섹션이 펼쳐진 상태에요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.defaultOpen !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig("defaultOpen", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
V2SectionCardConfigPanel.displayName = "V2SectionCardConfigPanel";
|
||||||
|
|
||||||
|
export default V2SectionCardConfigPanel;
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
import { ComponentCategory } from "@/types/component";
|
import { ComponentCategory } from "@/types/component";
|
||||||
import { SectionCardComponent } from "./SectionCardComponent";
|
import { SectionCardComponent } from "./SectionCardComponent";
|
||||||
import { SectionCardConfigPanel } from "./SectionCardConfigPanel";
|
import { V2SectionCardConfigPanel } from "@/components/v2/config-panels/V2SectionCardConfigPanel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Section Card 컴포넌트 정의
|
* Section Card 컴포넌트 정의
|
||||||
|
|
@ -28,7 +28,7 @@ export const V2SectionCardDefinition = createComponentDefinition({
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
},
|
},
|
||||||
defaultSize: { width: 800, height: 250 },
|
defaultSize: { width: 800, height: 250 },
|
||||||
configPanel: SectionCardConfigPanel,
|
configPanel: V2SectionCardConfigPanel,
|
||||||
icon: "LayoutPanelTop",
|
icon: "LayoutPanelTop",
|
||||||
tags: ["섹션", "그룹", "카드", "컨테이너", "제목", "card"],
|
tags: ["섹션", "그룹", "카드", "컨테이너", "제목", "card"],
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,389 @@
|
||||||
|
import { chromium, Page, Browser } from "playwright";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost:9771";
|
||||||
|
const API_URL = "http://localhost:8080/api";
|
||||||
|
const OUTPUT_DIR = path.join(__dirname);
|
||||||
|
|
||||||
|
interface ComponentTestResult {
|
||||||
|
componentType: string;
|
||||||
|
screenId: number;
|
||||||
|
status: "pass" | "fail" | "no_panel" | "not_found" | "error";
|
||||||
|
errorMessage?: string;
|
||||||
|
consoleErrors: string[];
|
||||||
|
hasConfigPanel: boolean;
|
||||||
|
screenshot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPONENT_SCREEN_MAP: Record<string, number> = {
|
||||||
|
"v2-input": 60,
|
||||||
|
"v2-select": 71,
|
||||||
|
"v2-date": 77,
|
||||||
|
"v2-button-primary": 47,
|
||||||
|
"v2-text-display": 114,
|
||||||
|
"v2-table-list": 47,
|
||||||
|
"v2-table-search-widget": 79,
|
||||||
|
"v2-media": 74,
|
||||||
|
"v2-split-panel-layout": 74,
|
||||||
|
"v2-tabs-widget": 1011,
|
||||||
|
"v2-section-card": 1188,
|
||||||
|
"v2-section-paper": 202,
|
||||||
|
"v2-card-display": 83,
|
||||||
|
"v2-numbering-rule": 130,
|
||||||
|
"v2-repeater": 1188,
|
||||||
|
"v2-divider-line": 1195,
|
||||||
|
"v2-location-swap-selector": 1195,
|
||||||
|
"v2-category-manager": 135,
|
||||||
|
"v2-file-upload": 138,
|
||||||
|
"v2-pivot-grid": 2327,
|
||||||
|
"v2-rack-structure": 1575,
|
||||||
|
"v2-repeat-container": 2403,
|
||||||
|
"v2-split-line": 4151,
|
||||||
|
"v2-bom-item-editor": 4154,
|
||||||
|
"v2-process-work-standard": 4158,
|
||||||
|
"v2-aggregation-widget": 4119,
|
||||||
|
"flow-widget": 77,
|
||||||
|
"entity-search-input": 3986,
|
||||||
|
"select-basic": 4470,
|
||||||
|
"textarea-basic": 3986,
|
||||||
|
"selected-items-detail-input": 227,
|
||||||
|
"screen-split-panel": 1674,
|
||||||
|
"split-panel-layout2": 2089,
|
||||||
|
"universal-form-modal": 2180,
|
||||||
|
"v2-table-grouped": 79,
|
||||||
|
"v2-status-count": 4498,
|
||||||
|
"v2-timeline-scheduler": 79,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function login(page: Page): Promise<string> {
|
||||||
|
await page.goto(`${BASE_URL}/login`);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.getByPlaceholder("사용자 ID를 입력하세요").fill("wace");
|
||||||
|
await page.getByPlaceholder("비밀번호를 입력하세요").fill("qlalfqjsgh11");
|
||||||
|
await page.getByRole("button", { name: "로그인" }).click();
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem("authToken") || "");
|
||||||
|
console.log("Login token obtained:", token ? "YES" : "NO");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDesigner(page: Page, screenId: number): Promise<boolean> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"erp-tab-store",
|
||||||
|
JSON.stringify({
|
||||||
|
state: {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "tab-screenmng",
|
||||||
|
title: "화면 관리",
|
||||||
|
path: "/admin/screenMng/screenMngList",
|
||||||
|
isActive: true,
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeTabId: "tab-screenmng",
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${BASE_URL}/admin/screenMng/screenMngList?openDesigner=${screenId}`);
|
||||||
|
await page.waitForTimeout(8000);
|
||||||
|
|
||||||
|
const designerOpen = await page.locator('[class*="designer"], [class*="canvas"], [data-testid*="designer"]').count();
|
||||||
|
const hasComponents = await page.locator('[data-component-id], [class*="component-wrapper"]').count();
|
||||||
|
console.log(` Designer elements: ${designerOpen}, Components: ${hasComponents}`);
|
||||||
|
return designerOpen > 0 || hasComponents > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testComponentConfigPanel(
|
||||||
|
page: Page,
|
||||||
|
componentType: string,
|
||||||
|
screenId: number
|
||||||
|
): Promise<ComponentTestResult> {
|
||||||
|
const result: ComponentTestResult = {
|
||||||
|
componentType,
|
||||||
|
screenId,
|
||||||
|
status: "error",
|
||||||
|
consoleErrors: [],
|
||||||
|
hasConfigPanel: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
if (msg.type() === "error") {
|
||||||
|
consoleErrors.push(msg.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on("pageerror", (err) => {
|
||||||
|
pageErrors.push(err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const opened = await openDesigner(page, screenId);
|
||||||
|
if (!opened) {
|
||||||
|
result.status = "error";
|
||||||
|
result.errorMessage = "Designer failed to open";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// find component by its url or type attribute in the DOM
|
||||||
|
const componentUrl = componentType.startsWith("v2-")
|
||||||
|
? `@/lib/registry/components/${componentType}`
|
||||||
|
: `@/lib/registry/components/${componentType}`;
|
||||||
|
|
||||||
|
// Try clicking on a component of this type
|
||||||
|
// The screen designer renders components with data attributes
|
||||||
|
const componentSelector = `[data-component-type="${componentType}"], [data-component-url*="${componentType}"]`;
|
||||||
|
const componentCount = await page.locator(componentSelector).count();
|
||||||
|
|
||||||
|
if (componentCount === 0) {
|
||||||
|
// Try alternative: look for components in the canvas by clicking around
|
||||||
|
// First try to find any clickable component wrapper
|
||||||
|
const wrappers = page.locator('[data-component-id]');
|
||||||
|
const wrapperCount = await wrappers.count();
|
||||||
|
|
||||||
|
if (wrapperCount === 0) {
|
||||||
|
result.status = "not_found";
|
||||||
|
result.errorMessage = `No components found in screen ${screenId}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click the first component to see if panel opens
|
||||||
|
let foundTarget = false;
|
||||||
|
for (let i = 0; i < Math.min(wrapperCount, 20); i++) {
|
||||||
|
try {
|
||||||
|
await wrappers.nth(i).click({ force: true, timeout: 2000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Check if the properties panel shows the right component type
|
||||||
|
const panelText = await page.locator('[class*="properties"], [class*="config-panel"], [class*="setting"]').textContent().catch(() => "");
|
||||||
|
if (panelText && panelText.includes(componentType)) {
|
||||||
|
foundTarget = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundTarget) {
|
||||||
|
result.status = "not_found";
|
||||||
|
result.errorMessage = `Component type "${componentType}" not clickable in screen ${screenId}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await page.locator(componentSelector).first().click({ force: true });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for the config panel in the right sidebar
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Look for config panel indicators
|
||||||
|
const configPanelVisible = await page.evaluate(() => {
|
||||||
|
// Check for error boundaries or error messages
|
||||||
|
const errorElements = document.querySelectorAll('[class*="error"], [class*="Error"]');
|
||||||
|
const errorTexts: string[] = [];
|
||||||
|
errorElements.forEach((el) => {
|
||||||
|
const text = el.textContent || "";
|
||||||
|
if (text.includes("로드 실패") || text.includes("에러") || text.includes("Error") || text.includes("Cannot read")) {
|
||||||
|
errorTexts.push(text.substring(0, 200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for config panel elements
|
||||||
|
const hasTabs = document.querySelectorAll('button[role="tab"]').length > 0;
|
||||||
|
const hasLabels = document.querySelectorAll("label").length > 0;
|
||||||
|
const hasInputs = document.querySelectorAll('input, select, [role="combobox"]').length > 0;
|
||||||
|
const hasConfigContent = document.querySelectorAll('[class*="config"], [class*="panel"], [class*="properties"]').length > 0;
|
||||||
|
const hasEditTab = Array.from(document.querySelectorAll("button")).some((b) => b.textContent?.includes("편집"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
errorTexts,
|
||||||
|
hasTabs,
|
||||||
|
hasLabels,
|
||||||
|
hasInputs,
|
||||||
|
hasConfigContent,
|
||||||
|
hasEditTab,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
result.hasConfigPanel = configPanelVisible.hasConfigContent || configPanelVisible.hasEditTab;
|
||||||
|
|
||||||
|
// Take screenshot
|
||||||
|
const screenshotName = `${componentType.replace(/[^a-zA-Z0-9-]/g, "_")}.png`;
|
||||||
|
const screenshotPath = path.join(OUTPUT_DIR, screenshotName);
|
||||||
|
await page.screenshot({ path: screenshotPath, fullPage: false });
|
||||||
|
result.screenshot = screenshotName;
|
||||||
|
|
||||||
|
// Collect errors
|
||||||
|
result.consoleErrors = [...consoleErrors, ...pageErrors];
|
||||||
|
|
||||||
|
if (configPanelVisible.errorTexts.length > 0) {
|
||||||
|
result.status = "fail";
|
||||||
|
result.errorMessage = configPanelVisible.errorTexts.join("; ");
|
||||||
|
} else if (pageErrors.length > 0) {
|
||||||
|
result.status = "fail";
|
||||||
|
result.errorMessage = pageErrors.join("; ");
|
||||||
|
} else if (consoleErrors.some((e) => e.includes("Cannot read") || e.includes("is not a function") || e.includes("undefined"))) {
|
||||||
|
result.status = "fail";
|
||||||
|
result.errorMessage = consoleErrors.filter((e) => e.includes("Cannot read") || e.includes("is not a function")).join("; ");
|
||||||
|
} else {
|
||||||
|
result.status = "pass";
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
result.status = "error";
|
||||||
|
result.errorMessage = err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("=== Config Panel Full Audit ===");
|
||||||
|
console.log(`Testing ${Object.keys(COMPONENT_SCREEN_MAP).length} component types\n`);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// Login
|
||||||
|
const token = await login(page);
|
||||||
|
if (!token) {
|
||||||
|
console.error("Login failed!");
|
||||||
|
await browser.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: ComponentTestResult[] = [];
|
||||||
|
const componentTypes = Object.keys(COMPONENT_SCREEN_MAP);
|
||||||
|
|
||||||
|
for (let i = 0; i < componentTypes.length; i++) {
|
||||||
|
const componentType = componentTypes[i];
|
||||||
|
const screenId = COMPONENT_SCREEN_MAP[componentType];
|
||||||
|
console.log(`\n[${i + 1}/${componentTypes.length}] Testing: ${componentType} (screen: ${screenId})`);
|
||||||
|
|
||||||
|
const result = await testComponentConfigPanel(page, componentType, screenId);
|
||||||
|
results.push(result);
|
||||||
|
|
||||||
|
const statusEmoji = {
|
||||||
|
pass: "OK",
|
||||||
|
fail: "FAIL",
|
||||||
|
no_panel: "NO_PANEL",
|
||||||
|
not_found: "NOT_FOUND",
|
||||||
|
error: "ERROR",
|
||||||
|
}[result.status];
|
||||||
|
console.log(` Result: ${statusEmoji} ${result.errorMessage || ""}`);
|
||||||
|
|
||||||
|
// Clear console listeners for next iteration
|
||||||
|
page.removeAllListeners("console");
|
||||||
|
page.removeAllListeners("pageerror");
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
// Write results
|
||||||
|
const reportPath = path.join(OUTPUT_DIR, "audit-results.json");
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(results, null, 2));
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log("\n\n=== AUDIT SUMMARY ===");
|
||||||
|
const passed = results.filter((r) => r.status === "pass");
|
||||||
|
const failed = results.filter((r) => r.status === "fail");
|
||||||
|
const errors = results.filter((r) => r.status === "error");
|
||||||
|
const notFound = results.filter((r) => r.status === "not_found");
|
||||||
|
|
||||||
|
console.log(`PASS: ${passed.length}`);
|
||||||
|
console.log(`FAIL: ${failed.length}`);
|
||||||
|
console.log(`ERROR: ${errors.length}`);
|
||||||
|
console.log(`NOT_FOUND: ${notFound.length}`);
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.log("\n--- FAILED Components ---");
|
||||||
|
failed.forEach((r) => {
|
||||||
|
console.log(` ${r.componentType} (screen: ${r.screenId}): ${r.errorMessage}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.log("\n--- ERROR Components ---");
|
||||||
|
errors.forEach((r) => {
|
||||||
|
console.log(` ${r.componentType} (screen: ${r.screenId}): ${r.errorMessage}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write markdown report
|
||||||
|
const mdReport = generateMarkdownReport(results);
|
||||||
|
fs.writeFileSync(path.join(OUTPUT_DIR, "audit-report.md"), mdReport);
|
||||||
|
console.log(`\nReport saved to ${OUTPUT_DIR}/audit-report.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMarkdownReport(results: ComponentTestResult[]): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("# Config Panel Audit Report");
|
||||||
|
lines.push(`\nDate: ${new Date().toISOString()}`);
|
||||||
|
lines.push(`\nTotal: ${results.length} components tested\n`);
|
||||||
|
|
||||||
|
const passed = results.filter((r) => r.status === "pass");
|
||||||
|
const failed = results.filter((r) => r.status === "fail");
|
||||||
|
const errors = results.filter((r) => r.status === "error");
|
||||||
|
const notFound = results.filter((r) => r.status === "not_found");
|
||||||
|
|
||||||
|
lines.push(`| Status | Count |`);
|
||||||
|
lines.push(`|--------|-------|`);
|
||||||
|
lines.push(`| PASS | ${passed.length} |`);
|
||||||
|
lines.push(`| FAIL | ${failed.length} |`);
|
||||||
|
lines.push(`| ERROR | ${errors.length} |`);
|
||||||
|
lines.push(`| NOT_FOUND | ${notFound.length} |`);
|
||||||
|
|
||||||
|
lines.push(`\n## Failed Components\n`);
|
||||||
|
if (failed.length === 0) {
|
||||||
|
lines.push("None\n");
|
||||||
|
} else {
|
||||||
|
lines.push(`| Component | Screen ID | Error |`);
|
||||||
|
lines.push(`|-----------|-----------|-------|`);
|
||||||
|
failed.forEach((r) => {
|
||||||
|
lines.push(`| ${r.componentType} | ${r.screenId} | ${(r.errorMessage || "").substring(0, 100)} |`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`\n## Error Components\n`);
|
||||||
|
if (errors.length === 0) {
|
||||||
|
lines.push("None\n");
|
||||||
|
} else {
|
||||||
|
lines.push(`| Component | Screen ID | Error |`);
|
||||||
|
lines.push(`|-----------|-----------|-------|`);
|
||||||
|
errors.forEach((r) => {
|
||||||
|
lines.push(`| ${r.componentType} | ${r.screenId} | ${(r.errorMessage || "").substring(0, 100)} |`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`\n## Not Found Components\n`);
|
||||||
|
if (notFound.length === 0) {
|
||||||
|
lines.push("None\n");
|
||||||
|
} else {
|
||||||
|
notFound.forEach((r) => {
|
||||||
|
lines.push(`- ${r.componentType} (screen: ${r.screenId}): ${r.errorMessage}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`\n## All Results\n`);
|
||||||
|
lines.push(`| # | Component | Screen | Status | Config Panel | Error |`);
|
||||||
|
lines.push(`|---|-----------|--------|--------|--------------|-------|`);
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
const status = r.status.toUpperCase();
|
||||||
|
lines.push(
|
||||||
|
`| ${i + 1} | ${r.componentType} | ${r.screenId} | ${status} | ${r.hasConfigPanel ? "Yes" : "No"} | ${(r.errorMessage || "-").substring(0, 80)} |`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const BASE = "http://localhost:9771";
|
||||||
|
const OUT = path.join(__dirname);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
|
||||||
|
const jsErrors: string[] = [];
|
||||||
|
page.on("pageerror", (e) => jsErrors.push(e.message));
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
if (msg.type() === "error") jsErrors.push("[console.error] " + msg.text().substring(0, 200));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) Login
|
||||||
|
console.log("1) Logging in...");
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await page.fill('[placeholder="사용자 ID를 입력하세요"]', "wace");
|
||||||
|
await page.fill('[placeholder="비밀번호를 입력하세요"]', "qlalfqjsgh11");
|
||||||
|
await page.click('button:has-text("로그인")');
|
||||||
|
await page.waitForTimeout(6000);
|
||||||
|
await page.screenshot({ path: path.join(OUT, "01-after-login.png") });
|
||||||
|
console.log(" URL after login:", page.url());
|
||||||
|
|
||||||
|
// 2) Open designer for screen 60 (has v2-input)
|
||||||
|
console.log("\n2) Opening designer for screen 60...");
|
||||||
|
await page.evaluate(() => {
|
||||||
|
sessionStorage.setItem("erp-tab-store", JSON.stringify({
|
||||||
|
state: { tabs: [{ id: "t", title: "화면관리", path: "/admin/screenMng/screenMngList", isActive: true, isPinned: false }], activeTabId: "t" },
|
||||||
|
version: 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
await page.goto(`${BASE}/admin/screenMng/screenMngList?openDesigner=60`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||||
|
await page.waitForTimeout(12000);
|
||||||
|
await page.screenshot({ path: path.join(OUT, "02-designer-loaded.png") });
|
||||||
|
|
||||||
|
// 3) Check DOM for components
|
||||||
|
const domInfo = await page.evaluate(() => {
|
||||||
|
const compWrappers = document.querySelectorAll("[data-component-id]");
|
||||||
|
const ids = Array.from(compWrappers).map(el => el.getAttribute("data-component-id"));
|
||||||
|
const bodyText = document.body.innerText.substring(0, 500);
|
||||||
|
const hasCanvas = !!document.querySelector('[class*="canvas"], [class*="designer-content"]');
|
||||||
|
const allClasses = Array.from(document.querySelectorAll("[class]"))
|
||||||
|
.map(el => el.className)
|
||||||
|
.filter(c => typeof c === "string" && (c.includes("canvas") || c.includes("designer") || c.includes("panel")))
|
||||||
|
.slice(0, 20);
|
||||||
|
return { componentIds: ids, hasCanvas, bodyTextPreview: bodyText, relevantClasses: allClasses };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(" Components in DOM:", domInfo.componentIds.length);
|
||||||
|
console.log(" Component IDs:", domInfo.componentIds.slice(0, 10));
|
||||||
|
console.log(" Has canvas:", domInfo.hasCanvas);
|
||||||
|
console.log(" Relevant classes:", domInfo.relevantClasses.slice(0, 5));
|
||||||
|
|
||||||
|
// 4) If components found, click the first one
|
||||||
|
if (domInfo.componentIds.length > 0) {
|
||||||
|
const firstId = domInfo.componentIds[0];
|
||||||
|
console.log(`\n3) Clicking component: ${firstId}`);
|
||||||
|
try {
|
||||||
|
await page.click(`[data-component-id="${firstId}"]`, { force: true, timeout: 5000 });
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await page.screenshot({ path: path.join(OUT, "03-component-clicked.png") });
|
||||||
|
|
||||||
|
// Check right panel
|
||||||
|
const panelInfo = await page.evaluate(() => {
|
||||||
|
const labels = document.querySelectorAll("label");
|
||||||
|
const inputs = document.querySelectorAll("input");
|
||||||
|
const selects = document.querySelectorAll('[role="combobox"]');
|
||||||
|
const tabs = document.querySelectorAll('[role="tab"]');
|
||||||
|
const switches = document.querySelectorAll('[role="switch"]');
|
||||||
|
const rightPanel = document.querySelector('[class*="right"], [class*="sidebar"], [class*="properties"]');
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: labels.length,
|
||||||
|
inputs: inputs.length,
|
||||||
|
selects: selects.length,
|
||||||
|
tabs: tabs.length,
|
||||||
|
switches: switches.length,
|
||||||
|
hasRightPanel: !!rightPanel,
|
||||||
|
rightPanelText: rightPanel?.textContent?.substring(0, 300) || "(no panel found)",
|
||||||
|
errorTexts: Array.from(document.querySelectorAll('[class*="error"], [class*="destructive"]'))
|
||||||
|
.map(el => (el as HTMLElement).innerText?.substring(0, 100))
|
||||||
|
.filter(t => t && t.length > 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(" Panel info:", JSON.stringify(panelInfo, null, 2));
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(" Click failed:", e.message.substring(0, 100));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("\n No components found in DOM - designer may not have loaded");
|
||||||
|
console.log(" Body preview:", domInfo.bodyTextPreview.substring(0, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Try clicking on different areas of the page to find components
|
||||||
|
if (domInfo.componentIds.length === 0) {
|
||||||
|
console.log("\n4) Trying to find canvas area by clicking...");
|
||||||
|
// Take full page screenshot to see what's there
|
||||||
|
await page.screenshot({ path: path.join(OUT, "04-full-page.png"), fullPage: true });
|
||||||
|
|
||||||
|
// Get viewport-based content
|
||||||
|
const pageStructure = await page.evaluate(() => {
|
||||||
|
const allElements = document.querySelectorAll("div, section, main, aside");
|
||||||
|
const structure: string[] = [];
|
||||||
|
allElements.forEach(el => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width > 100 && rect.height > 100) {
|
||||||
|
const cls = el.className?.toString().substring(0, 50) || "";
|
||||||
|
const id = el.id || "";
|
||||||
|
structure.push(`${el.tagName}#${id}.${cls} [${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.width)}x${Math.round(rect.height)}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return structure.slice(0, 30);
|
||||||
|
});
|
||||||
|
console.log(" Large elements:");
|
||||||
|
pageStructure.forEach(s => console.log(" " + s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log("\n\n=== SUMMARY ===");
|
||||||
|
console.log(`JS Errors: ${jsErrors.length}`);
|
||||||
|
if (jsErrors.length > 0) {
|
||||||
|
const unique = [...new Set(jsErrors)];
|
||||||
|
console.log("Unique errors:");
|
||||||
|
unique.slice(0, 20).forEach(e => console.log(" " + e.substring(0, 200)));
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log("\nScreenshots saved to:", OUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "v2-input",
|
||||||
|
"screenId": 60,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-select",
|
||||||
|
"screenId": 71,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-date",
|
||||||
|
"screenId": 77,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "flow-widget",
|
||||||
|
"screenId": 77,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-button-primary",
|
||||||
|
"screenId": 50,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-text-display",
|
||||||
|
"screenId": 114,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-table-list",
|
||||||
|
"screenId": 68,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-table-search-widget",
|
||||||
|
"screenId": 79,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-media",
|
||||||
|
"screenId": 74,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-split-panel-layout",
|
||||||
|
"screenId": 74,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-tabs-widget",
|
||||||
|
"screenId": 1011,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-section-card",
|
||||||
|
"screenId": 1188,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-repeater",
|
||||||
|
"screenId": 1188,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-section-paper",
|
||||||
|
"screenId": 202,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-card-display",
|
||||||
|
"screenId": 83,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-numbering-rule",
|
||||||
|
"screenId": 130,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-divider-line",
|
||||||
|
"screenId": 1195,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-location-swap-selector",
|
||||||
|
"screenId": 1195,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-category-manager",
|
||||||
|
"screenId": 135,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-file-upload",
|
||||||
|
"screenId": 138,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-pivot-grid",
|
||||||
|
"screenId": 2327,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-rack-structure",
|
||||||
|
"screenId": 1575,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-repeat-container",
|
||||||
|
"screenId": 2403,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-split-line",
|
||||||
|
"screenId": 4151,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-bom-item-editor",
|
||||||
|
"screenId": 4154,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-process-work-standard",
|
||||||
|
"screenId": 4158,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "v2-aggregation-widget",
|
||||||
|
"screenId": 4119,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "entity-search-input",
|
||||||
|
"screenId": 3986,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "textarea-basic",
|
||||||
|
"screenId": 3986,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select-basic",
|
||||||
|
"screenId": 4470,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "selected-items-detail-input",
|
||||||
|
"screenId": 227,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "screen-split-panel",
|
||||||
|
"screenId": 1674,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "split-panel-layout2",
|
||||||
|
"screenId": 2089,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "universal-form-modal",
|
||||||
|
"screenId": 2180,
|
||||||
|
"compId": "",
|
||||||
|
"status": "NO_COMPONENT",
|
||||||
|
"jsErrors": [],
|
||||||
|
"panelDetails": "",
|
||||||
|
"errorMsg": "Component not found in layout API data"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
import { chromium, Page } from "playwright";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const BASE = "http://localhost:9771";
|
||||||
|
const OUT = path.join(__dirname);
|
||||||
|
|
||||||
|
const TARGETS: [string, number][] = [
|
||||||
|
["v2-input", 60],
|
||||||
|
["v2-select", 71],
|
||||||
|
["v2-date", 77],
|
||||||
|
["v2-button-primary", 50],
|
||||||
|
["v2-text-display", 114],
|
||||||
|
["v2-table-list", 68],
|
||||||
|
["v2-table-search-widget", 79],
|
||||||
|
["v2-media", 74],
|
||||||
|
["v2-split-panel-layout", 74],
|
||||||
|
["v2-tabs-widget", 1011],
|
||||||
|
["v2-section-card", 1188],
|
||||||
|
["v2-section-paper", 202],
|
||||||
|
["v2-card-display", 83],
|
||||||
|
["v2-numbering-rule", 130],
|
||||||
|
["v2-repeater", 1188],
|
||||||
|
["v2-divider-line", 1195],
|
||||||
|
["v2-location-swap-selector", 1195],
|
||||||
|
["v2-category-manager", 135],
|
||||||
|
["v2-file-upload", 138],
|
||||||
|
["v2-pivot-grid", 2327],
|
||||||
|
["v2-rack-structure", 1575],
|
||||||
|
["v2-repeat-container", 2403],
|
||||||
|
["v2-split-line", 4151],
|
||||||
|
["v2-bom-item-editor", 4154],
|
||||||
|
["v2-process-work-standard", 4158],
|
||||||
|
["v2-aggregation-widget", 4119],
|
||||||
|
["flow-widget", 77],
|
||||||
|
["entity-search-input", 3986],
|
||||||
|
["select-basic", 4470],
|
||||||
|
["textarea-basic", 3986],
|
||||||
|
["selected-items-detail-input", 227],
|
||||||
|
["screen-split-panel", 1674],
|
||||||
|
["split-panel-layout2", 2089],
|
||||||
|
["universal-form-modal", 2180],
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
type: string;
|
||||||
|
screenId: number;
|
||||||
|
compId: string;
|
||||||
|
status: "PASS" | "FAIL" | "ERROR" | "NO_COMPONENT";
|
||||||
|
jsErrors: string[];
|
||||||
|
panelDetails: string;
|
||||||
|
errorMsg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page: Page) {
|
||||||
|
await page.goto(`${BASE}/login`);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.fill('[placeholder="사용자 ID를 입력하세요"]', "wace");
|
||||||
|
await page.fill('[placeholder="비밀번호를 입력하세요"]', "qlalfqjsgh11");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.url().includes("/auth/login"), { timeout: 15000 }).catch(() => null),
|
||||||
|
page.click('button:has-text("로그인")'),
|
||||||
|
]);
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
const hasToken = await page.evaluate(() => !!localStorage.getItem("authToken"));
|
||||||
|
console.log("Login:", hasToken ? "OK" : "FAILED");
|
||||||
|
return hasToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLayoutComponents(page: Page, screenId: number): Promise<any[]> {
|
||||||
|
return page.evaluate(async (sid) => {
|
||||||
|
const token = localStorage.getItem("authToken") || "";
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const apiBase = host === "localhost" || host === "127.0.0.1"
|
||||||
|
? "http://localhost:8080/api"
|
||||||
|
: "/api";
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${apiBase}/screen-management/screens/${sid}/layout-v2`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success && data.data?.components) return data.data.components;
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
const all: any[] = [];
|
||||||
|
for (const layer of data.data) {
|
||||||
|
if (layer.layout_data?.components) all.push(...layer.layout_data.components);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch { return []; }
|
||||||
|
}, screenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCompId(components: any[], targetType: string): string {
|
||||||
|
for (const c of components) {
|
||||||
|
const url: string = c.url || "";
|
||||||
|
const ctype: string = c.componentType || "";
|
||||||
|
if (url.endsWith("/" + targetType) || ctype === targetType) return c.id;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDesigner(page: Page, screenId: number) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
sessionStorage.setItem("erp-tab-store", JSON.stringify({
|
||||||
|
state: {
|
||||||
|
tabs: [{ id: "tab-sm", title: "화면 관리", path: "/admin/screenMng/screenMngList", isActive: true, isPinned: false }],
|
||||||
|
activeTabId: "tab-sm",
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
await page.goto(`${BASE}/admin/screenMng/screenMngList?openDesigner=${screenId}`, {
|
||||||
|
timeout: 60000, waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPanel(page: Page): Promise<{ visible: boolean; hasError: boolean; detail: string }> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const body = document.body.innerText || "";
|
||||||
|
const hasError = body.includes("로드 실패") || body.includes("Cannot read properties");
|
||||||
|
|
||||||
|
const labels = document.querySelectorAll("label").length;
|
||||||
|
const inputs = document.querySelectorAll('input:not([type="hidden"])').length;
|
||||||
|
const selects = document.querySelectorAll('select, [role="combobox"], [role="listbox"]').length;
|
||||||
|
const tabs = document.querySelectorAll('[role="tab"]').length;
|
||||||
|
const switches = document.querySelectorAll('[role="switch"]').length;
|
||||||
|
|
||||||
|
const total = labels + inputs + selects + tabs + switches;
|
||||||
|
const detail = `L=${labels} I=${inputs} S=${selects} T=${tabs} SW=${switches}`;
|
||||||
|
return { visible: total > 3, hasError, detail };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("=== Config Panel Audit v3 ===\n");
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
|
||||||
|
const jsErrors: string[] = [];
|
||||||
|
page.on("pageerror", (err) => jsErrors.push(err.message.substring(0, 300)));
|
||||||
|
|
||||||
|
const ok = await login(page);
|
||||||
|
if (!ok) { await browser.close(); return; }
|
||||||
|
|
||||||
|
const groups = new Map<number, string[]>();
|
||||||
|
for (const [type, sid] of TARGETS) {
|
||||||
|
if (!groups.has(sid)) groups.set(sid, []);
|
||||||
|
groups.get(sid)!.push(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allResults: Result[] = [];
|
||||||
|
const entries = Array.from(groups.entries());
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const [screenId, types] = entries[i];
|
||||||
|
console.log(`\n[${i + 1}/${entries.length}] Screen ${screenId}: ${types.join(", ")}`);
|
||||||
|
|
||||||
|
const components = await getLayoutComponents(page, screenId);
|
||||||
|
console.log(` API: ${components.length} comps`);
|
||||||
|
|
||||||
|
await openDesigner(page, screenId);
|
||||||
|
|
||||||
|
const domCompCount = await page.locator('[data-component-id]').count();
|
||||||
|
console.log(` DOM: ${domCompCount} comp wrappers`);
|
||||||
|
|
||||||
|
for (const targetType of types) {
|
||||||
|
const errIdx = jsErrors.length;
|
||||||
|
const compId = findCompId(components, targetType);
|
||||||
|
|
||||||
|
if (!compId) {
|
||||||
|
console.log(` ${targetType}: NO_COMPONENT`);
|
||||||
|
allResults.push({ type: targetType, screenId, compId: "", status: "NO_COMPONENT", jsErrors: [], panelDetails: "", errorMsg: "Not in layout" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 클릭
|
||||||
|
let clicked = false;
|
||||||
|
const sel = `[data-component-id="${compId}"], #component-${compId}`;
|
||||||
|
const elCount = await page.locator(sel).count();
|
||||||
|
if (elCount > 0) {
|
||||||
|
try {
|
||||||
|
await page.locator(sel).first().click({ force: true, timeout: 5000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
clicked = true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await page.locator(sel).first().dispatchEvent("click");
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
clicked = true;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clicked) {
|
||||||
|
// fallback: 캔버스에서 위치 기반 클릭 시도
|
||||||
|
const comp = components.find((c: any) => c.id === compId);
|
||||||
|
if (comp?.position) {
|
||||||
|
const canvasEl = await page.locator('[class*="canvas"], [class*="designer"]').first().boundingBox();
|
||||||
|
if (canvasEl) {
|
||||||
|
const x = canvasEl.x + (comp.position.x || 100);
|
||||||
|
const y = canvasEl.y + (comp.position.y || 100);
|
||||||
|
await page.mouse.click(x, y);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
clicked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clicked) {
|
||||||
|
console.log(` ${targetType}: ERROR (click fail, id=${compId})`);
|
||||||
|
allResults.push({ type: targetType, screenId, compId, status: "ERROR", jsErrors: jsErrors.slice(errIdx), panelDetails: "", errorMsg: "Cannot click component" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = await checkPanel(page);
|
||||||
|
const newErrors = jsErrors.slice(errIdx);
|
||||||
|
const critical = newErrors.find((e) =>
|
||||||
|
e.includes("Cannot read") || e.includes("is not a function") || e.includes("is not defined") || e.includes("Minified React")
|
||||||
|
);
|
||||||
|
|
||||||
|
let status: Result["status"];
|
||||||
|
let errorMsg: string | undefined;
|
||||||
|
|
||||||
|
if (panel.hasError || critical) {
|
||||||
|
status = "FAIL";
|
||||||
|
errorMsg = critical || "Panel error";
|
||||||
|
} else if (!panel.visible) {
|
||||||
|
status = "FAIL";
|
||||||
|
errorMsg = `Panel not visible (${panel.detail})`;
|
||||||
|
} else {
|
||||||
|
status = "PASS";
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = { PASS: "OK", FAIL: "FAIL", ERROR: "ERR", NO_COMPONENT: "??" }[status];
|
||||||
|
console.log(` ${targetType}: ${icon} [${panel.detail}]${errorMsg ? " " + errorMsg.substring(0, 80) : ""}`);
|
||||||
|
|
||||||
|
if (status === "FAIL") {
|
||||||
|
await page.screenshot({ path: path.join(OUT, `fail-${targetType.replace(/\//g, "_")}.png`) }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.push({ type: targetType, screenId, compId, status, jsErrors: newErrors, panelDetails: panel.detail, errorMsg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(OUT, "results.json"), JSON.stringify(allResults, null, 2));
|
||||||
|
|
||||||
|
console.log("\n\n=== AUDIT SUMMARY ===");
|
||||||
|
const p = allResults.filter((r) => r.status === "PASS");
|
||||||
|
const f = allResults.filter((r) => r.status === "FAIL");
|
||||||
|
const e = allResults.filter((r) => r.status === "ERROR");
|
||||||
|
const n = allResults.filter((r) => r.status === "NO_COMPONENT");
|
||||||
|
|
||||||
|
console.log(`Total: ${allResults.length}`);
|
||||||
|
console.log(`PASS: ${p.length}`);
|
||||||
|
console.log(`FAIL: ${f.length}`);
|
||||||
|
console.log(`ERROR: ${e.length}`);
|
||||||
|
console.log(`NO_COMPONENT: ${n.length}`);
|
||||||
|
|
||||||
|
if (f.length > 0) {
|
||||||
|
console.log("\n--- FAILED ---");
|
||||||
|
f.forEach((r) => console.log(` ${r.type} (screen ${r.screenId}): ${r.errorMsg}`));
|
||||||
|
}
|
||||||
|
if (e.length > 0) {
|
||||||
|
console.log("\n--- ERRORS ---");
|
||||||
|
e.forEach((r) => console.log(` ${r.type} (screen ${r.screenId}): ${r.errorMsg}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = [...new Set(jsErrors)];
|
||||||
|
if (unique.length > 0) {
|
||||||
|
console.log(`\n--- JS Errors (${unique.length} unique) ---`);
|
||||||
|
unique.slice(0, 15).forEach((err) => console.log(` ${err.substring(0, 150)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nDone.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 86 KiB |