[agent-pipeline] pipe-20260309112447-f5iu round-1
This commit is contained in:
parent
e4de414dfb
commit
074abfcdb0
|
|
@ -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<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: string;
|
||||
render?: (value: any, row: T, index: number) => ReactNode;
|
||||
hideOnMobile?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 카드 필드 타입
|
||||
export interface RDVCardField<T> {
|
||||
label: string;
|
||||
render: (item: T) => ReactNode;
|
||||
hideEmpty?: boolean;
|
||||
}
|
||||
|
||||
// 메인 Props 타입
|
||||
export interface ResponsiveDataViewProps<T> {
|
||||
data: T[];
|
||||
columns: RDVColumn<T>[];
|
||||
keyExtractor: (item: T) => string;
|
||||
|
||||
// 로딩/빈 상태
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
skeletonCount?: number;
|
||||
|
||||
// 카드 설정 (모바일)
|
||||
cardTitle: (item: T) => ReactNode;
|
||||
cardSubtitle?: (item: T) => ReactNode;
|
||||
cardHeaderRight?: (item: T) => ReactNode;
|
||||
cardFields?: RDVCardField<T>[] | ((item: T) => RDVCardField<T>[]);
|
||||
|
||||
// 액션 (테이블 마지막 컬럼 + 카드 하단)
|
||||
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<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
skeletonCount = 5,
|
||||
cardTitle,
|
||||
cardSubtitle,
|
||||
cardHeaderRight,
|
||||
cardFields,
|
||||
renderActions,
|
||||
actionsLabel,
|
||||
actionsWidth,
|
||||
onRowClick,
|
||||
tableContainerClassName,
|
||||
cardContainerClassName,
|
||||
}: ResponsiveDataViewProps<T>) {
|
||||
// cardFields 미지정 시 columns에서 자동 생성
|
||||
function resolveCardFields(item: T): RDVCardField<T>[] {
|
||||
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 (
|
||||
<>
|
||||
{/* 데스크톱 테이블 스켈레톤 */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden rounded-lg border bg-card shadow-sm lg:block",
|
||||
tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className="h-12 text-sm font-semibold"
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className="h-12 text-sm font-semibold"
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: skeletonCount }).map((_, rowIdx) => (
|
||||
<TableRow key={rowIdx} className="border-b">
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} className="h-16">
|
||||
<div className="h-4 animate-pulse rounded bg-muted" />
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className="h-16">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일 카드 스켈레톤 */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-4 sm:grid-cols-2 lg:hidden",
|
||||
cardContainerClassName
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-32 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-6 w-12 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<div key={j} className="flex justify-between">
|
||||
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderActions && (
|
||||
<div className="mt-3 flex gap-2 border-t pt-3">
|
||||
<div className="h-9 flex-1 animate-pulse rounded bg-muted" />
|
||||
<div className="h-9 flex-1 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 빈 상태 ---
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
|
||||
{emptyMessage || "데이터가 없습니다."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 실제 데이터 렌더링 ---
|
||||
return (
|
||||
<>
|
||||
{/* 데스크톱 테이블 (lg 이상) */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden rounded-lg border bg-card shadow-sm lg:block",
|
||||
tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className="h-12 text-sm font-semibold"
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className="h-12 text-sm font-semibold"
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item, index) => (
|
||||
<TableRow
|
||||
key={keyExtractor(item)}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50",
|
||||
onRowClick && "cursor-pointer"
|
||||
)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn("h-16 text-sm", col.className)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(getNestedValue(item, col.key), item, index)
|
||||
: String(getNestedValue(item, col.key) ?? "-")}
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className="h-16 text-sm">
|
||||
<div className="flex gap-2">{renderActions(item)}</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일 카드 (lg 미만) */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-4 sm:grid-cols-2 lg:hidden",
|
||||
cardContainerClassName
|
||||
)}
|
||||
>
|
||||
{data.map((item) => {
|
||||
const fields = resolveCardFields(item);
|
||||
return (
|
||||
<div
|
||||
key={keyExtractor(item)}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50",
|
||||
onRowClick && "cursor-pointer"
|
||||
)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{/* 카드 헤더 */}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-base font-semibold">
|
||||
{cardTitle(item)}
|
||||
</h3>
|
||||
{cardSubtitle && (
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
||||
{cardSubtitle(item)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{cardHeaderRight && (
|
||||
<div className="ml-2 shrink-0">{cardHeaderRight(item)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 카드 필드 */}
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-1.5 border-t pt-3">
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="font-medium">{field.render(item)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 액션 */}
|
||||
{renderActions && (
|
||||
<div className="mt-3 flex gap-2 border-t pt-3">
|
||||
{renderActions(item)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
Loading…
Reference in New Issue