diff --git a/frontend/components/common/ResponsiveDataView.tsx b/frontend/components/common/ResponsiveDataView.tsx new file mode 100644 index 00000000..226225f4 --- /dev/null +++ b/frontend/components/common/ResponsiveDataView.tsx @@ -0,0 +1,328 @@ +"use client"; + +import React, { ReactNode } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; + +// 컬럼 정의 타입 +export interface RDVColumn { + key: string; + label: string; + width?: string; + render?: (value: any, row: T, index: number) => ReactNode; + hideOnMobile?: boolean; + className?: string; +} + +// 카드 필드 타입 +export interface RDVCardField { + label: string; + render: (item: T) => ReactNode; + hideEmpty?: boolean; +} + +// 메인 Props 타입 +export interface ResponsiveDataViewProps { + data: T[]; + columns: RDVColumn[]; + keyExtractor: (item: T) => string; + + // 로딩/빈 상태 + isLoading?: boolean; + emptyMessage?: string; + skeletonCount?: number; + + // 카드 설정 (모바일) + cardTitle: (item: T) => ReactNode; + cardSubtitle?: (item: T) => ReactNode; + cardHeaderRight?: (item: T) => ReactNode; + cardFields?: RDVCardField[] | ((item: T) => RDVCardField[]); + + // 액션 (테이블 마지막 컬럼 + 카드 하단) + renderActions?: (item: T) => ReactNode; + actionsLabel?: string; + actionsWidth?: string; + + // 행 클릭 + onRowClick?: (item: T) => void; + + // 스타일 커스터마이징 + tableContainerClassName?: string; + cardContainerClassName?: string; +} + +// 중첩 객체에서 키 경로로 값을 꺼내는 헬퍼 +function getNestedValue(obj: any, path: string): any { + return path.split(".").reduce((acc, key) => acc?.[key], obj); +} + +export function ResponsiveDataView({ + data, + columns, + keyExtractor, + isLoading = false, + emptyMessage, + skeletonCount = 5, + cardTitle, + cardSubtitle, + cardHeaderRight, + cardFields, + renderActions, + actionsLabel, + actionsWidth, + onRowClick, + tableContainerClassName, + cardContainerClassName, +}: ResponsiveDataViewProps) { + // cardFields 미지정 시 columns에서 자동 생성 + function resolveCardFields(item: T): RDVCardField[] { + if (typeof cardFields === "function") return cardFields(item); + if (Array.isArray(cardFields)) return cardFields; + return columns + .filter((col) => !col.hideOnMobile) + .map((col) => ({ + label: col.label, + render: (row: T) => + col.render + ? col.render(getNestedValue(row, col.key), row, 0) + : String(getNestedValue(row, col.key) ?? "-"), + })); + } + + // --- 로딩 스켈레톤 --- + if (isLoading) { + return ( + <> + {/* 데스크톱 테이블 스켈레톤 */} +
+ + + + {columns.map((col) => ( + + {col.label} + + ))} + {renderActions && ( + + {actionsLabel || "작업"} + + )} + + + + {Array.from({ length: skeletonCount }).map((_, rowIdx) => ( + + {columns.map((col) => ( + +
+ + ))} + {renderActions && ( + +
+
+
+
+ + )} + + ))} + +
+
+ + {/* 모바일 카드 스켈레톤 */} +
+ {Array.from({ length: skeletonCount }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+
+
+
+ ))} +
+ {renderActions && ( +
+
+
+
+ )} +
+ ))} +
+ + ); + } + + // --- 빈 상태 --- + if (data.length === 0) { + return ( +
+ {emptyMessage || "데이터가 없습니다."} +
+ ); + } + + // --- 실제 데이터 렌더링 --- + return ( + <> + {/* 데스크톱 테이블 (lg 이상) */} +
+ + + + {columns.map((col) => ( + + {col.label} + + ))} + {renderActions && ( + + {actionsLabel || "작업"} + + )} + + + + {data.map((item, index) => ( + onRowClick?.(item)} + > + {columns.map((col) => ( + + {col.render + ? col.render(getNestedValue(item, col.key), item, index) + : String(getNestedValue(item, col.key) ?? "-")} + + ))} + {renderActions && ( + +
{renderActions(item)}
+
+ )} +
+ ))} +
+
+
+ + {/* 모바일 카드 (lg 미만) */} +
+ {data.map((item) => { + const fields = resolveCardFields(item); + return ( +
onRowClick?.(item)} + > + {/* 카드 헤더 */} +
+
+

+ {cardTitle(item)} +

+ {cardSubtitle && ( +

+ {cardSubtitle(item)} +

+ )} +
+ {cardHeaderRight && ( +
{cardHeaderRight(item)}
+ )} +
+ + {/* 카드 필드 */} + {fields.length > 0 && ( +
+ {fields.map((field, i) => ( +
+ + {field.label} + + {field.render(item)} +
+ ))} +
+ )} + + {/* 카드 액션 */} + {renderActions && ( +
+ {renderActions(item)} +
+ )} +
+ ); + })} +
+ + ); +} + diff --git a/frontend/scripts/dashboard-verification.ts b/frontend/scripts/dashboard-verification.ts new file mode 100644 index 00000000..2a3a7bf0 --- /dev/null +++ b/frontend/scripts/dashboard-verification.ts @@ -0,0 +1,71 @@ +/** + * 대시보드 검증 스크립트 + * 1. 로그인 + * 2. /main으로 강제 이동 (reload) + * 3. 대시보드 스크린샷 + */ + +import { chromium } from "playwright"; +import * as path from "path"; +import * as fs from "fs"; + +const BASE_URL = "http://localhost:9771"; +const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots"); + +async function main() { + if (!fs.existsSync(SCREENSHOT_DIR)) { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + } + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + + try { + // Step 1: 로그인 페이지 접속 + console.log("Step 1: 로그인 페이지 접속..."); + await page.goto(`${BASE_URL}/login`, { waitUntil: "commit", timeout: 30000 }); + await page.waitForTimeout(1000); + + // Step 2: 로그인 + console.log("Step 2: 로그인..."); + await page.fill("#userId", "wace"); + await page.fill("#password", "qlalfqjsgh11"); + await page.click('button[type="submit"]'); + + // Step 3: 리다이렉트 대기 (최대 15초) + console.log("Step 3: 페이지 로드 대기 (최대 15초)..."); + await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 15000 }).catch(() => {}); + await page.waitForTimeout(4000); // 쿠키/토큰 설정 완료 대기 + + // Step 4: /main으로 강제 이동 (reload) + console.log("Step 4: /main으로 강제 이동..."); + await page.goto(`${BASE_URL}/main`, { waitUntil: "commit", timeout: 30000 }); + await page.waitForTimeout(3000); // 페이지 렌더링 대기 + + // Step 5: 페이지 내용 검증 및 스크린샷 + const heading = await page.locator("h1").first().textContent().catch(() => ""); + const url = page.url(); + console.log("Step 5: 현재 URL:", url); + console.log(" -> h1 제목:", heading?.trim() || "(없음)"); + + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "main-dashboard.png"), + fullPage: true, + }); + console.log(" -> main-dashboard.png 저장됨"); + + await browser.close(); + console.log("\n검증 완료. 스크린샷:", path.join(SCREENSHOT_DIR, "main-dashboard.png")); + } catch (error) { + console.error("오류:", error); + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "dashboard-error.png"), + fullPage: true, + }).catch(() => {}); + await browser.close(); + process.exit(1); + } +} + +main(); diff --git a/frontend/scripts/ui-redesign-verification.ts b/frontend/scripts/ui-redesign-verification.ts new file mode 100644 index 00000000..ceb78c3f --- /dev/null +++ b/frontend/scripts/ui-redesign-verification.ts @@ -0,0 +1,112 @@ +/** + * UI 리디자인 검증 스크립트 + * 1. 로그인 페이지 스크린샷 + * 2. 로그인 + * 3. 대시보드 스크린샷 + * 4. 사이드바 스크린샷 + */ + +import { chromium } from "playwright"; +import * as path from "path"; +import * as fs from "fs"; + +const BASE_URL = "http://localhost:9771"; +const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots"); + +async function main() { + if (!fs.existsSync(SCREENSHOT_DIR)) { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + } + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + + try { + // Step 1: 로그인 페이지 접속 및 스크린샷 + console.log("Step 1: 로그인 페이지 접속..."); + await page.goto(`${BASE_URL}/login`, { waitUntil: "commit", timeout: 30000 }); + await page.waitForTimeout(1500); + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "01-login-page.png"), + fullPage: true, + }); + console.log(" -> 01-login-page.png 저장됨"); + + // Step 2: 로그인 + console.log("Step 2: 로그인..."); + await page.fill("#userId", "admin"); + await page.fill("#password", "1234"); + await page.click('button[type="submit"]'); + await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(3000); + + const currentUrl = page.url(); + if (currentUrl.includes("/login")) { + console.log(" -> 로그인 실패, 현재 URL:", currentUrl); + } else { + console.log(" -> 로그인 성공, 리다이렉트:", currentUrl); + + // Step 3: 메인 페이지로 이동 (대시보드) + if (!currentUrl.includes("/main") && !currentUrl.includes("/admin")) { + await page.goto(`${BASE_URL}/main`, { waitUntil: "load", timeout: 20000 }); + await page.waitForTimeout(2000); + } + + // 대시보드 전체 스크린샷 + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "02-dashboard.png"), + fullPage: true, + }); + console.log(" -> 02-dashboard.png 저장됨"); + + // Step 4: 사이드바 포커스 스크린샷 (좌측 영역) + const sidebar = page.locator("aside"); + if ((await sidebar.count()) > 0) { + await sidebar.first().screenshot({ + path: path.join(SCREENSHOT_DIR, "03-sidebar.png"), + }); + console.log(" -> 03-sidebar.png 저장됨"); + } + + // Step 5: 테이블/그리드 화면으로 이동하여 스타일 확인 + console.log("Step 5: 테이블 화면 탐색..."); + const menuLinks = page.locator('aside a[href*="/screens/"], aside [role="button"]'); + const linkCount = await menuLinks.count(); + if (linkCount > 0) { + await menuLinks.first().click(); + await page.waitForTimeout(2500); + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "04-table-screen.png"), + fullPage: false, + }); + console.log(" -> 04-table-screen.png 저장됨"); + } else { + // 메뉴 클릭으로 화면 이동 시도 + const firstMenu = page.locator('aside [class*="cursor-pointer"]').first(); + if ((await firstMenu.count()) > 0) { + await firstMenu.click(); + await page.waitForTimeout(2500); + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "04-table-screen.png"), + fullPage: false, + }); + console.log(" -> 04-table-screen.png 저장됨"); + } + } + } + + await browser.close(); + console.log("\n검증 완료. 스크린샷:", SCREENSHOT_DIR); + } catch (error) { + console.error("오류:", error); + await page.screenshot({ + path: path.join(SCREENSHOT_DIR, "error.png"), + fullPage: true, + }).catch(() => {}); + await browser.close(); + process.exit(1); + } +} + +main();