페이지 템플릿 4종(테이블/로그/차트/시트)
0) 준비 상태 점검
- Next.js(App Router) + Tailwind v4 + shadcn/ui + lucide-react + recharts 설치 완료
src/app/globals.css에 Tailwind v4 스타일(@import "tailwindcss";) 들어있음- 탭 제목은
naver.how으로 설정되어 있음(시리즈 2/6)
1) 라우트 키 & 메뉴 그룹 정의 (한 곳에서 관리)
파일: src/config/ia.ts
// src/config/ia.ts
import type { LucideIcon } from "lucide-react";
import {
Activity, Database, ListChecks, Shield, Users, FileText,
} from "lucide-react";
// 라우트 키(화면 식별자)
export type RouteKey =
| "dashboard"
// 수집·크롤링
| "ingest-requests" | "ingest-crawl-logs" | "ingest-deepweb"
// 스크린샷 · 파일 자산
| "assets-screenshots" | "assets-screenshot-hashes" | "assets-files"
// 분석 · 탐지
| "analysis-domain" | "analysis-file" | "analysis-ip" | "analysis-js" | "analysis-risk-models"
// 판정 · 검수
| "adj-compare" | "adj-mismatch" | "adj-fp-suspects" | "adj-fp-final-edits" | "adj-fp-reports" | "adj-phishing"
// 인텔리전스 DB
| "intel-url-db" | "intel-domain-db" | "intel-bkb-db"
// 서비스 운영
| "ops-users" | "ops-admins" | "ops-initial-passwords" | "ops-billing" | "ops-bookmarks" | "ops-search-history" | "ops-login-stats" | "ops-search-stats"
// 접근 기록 · 감사
| "audit-user-access" | "audit-admin-access" | "audit-user-auth" | "audit-admin-auth" | "audit-system-ops"
// 설정
| "settings";
export interface MenuItem { key: RouteKey; label: string; icon: LucideIcon }
export interface MenuGroup { label: string; items: MenuItem[] }
// 사이드바 그룹(IA)
export const menuGroups: MenuGroup[] = [
{ label: "개요", items: [{ key: "dashboard", label: "대시보드", icon: Activity }] },
{
label: "수집·크롤링",
items: [
{ key: "ingest-requests", label: "웹 요청 관리", icon: Database },
{ key: "ingest-crawl-logs", label: "크롤링 로그 관리", icon: Database },
{ key: "ingest-deepweb", label: "딥웹 관리", icon: Database },
],
},
{
label: "스크린샷 · 파일 자산",
items: [
{ key: "assets-screenshots", label: "웹 스크린샷 관리", icon: FileText },
{ key: "assets-screenshot-hashes", label: "스크린샷 해시 관리", icon: FileText },
{ key: "assets-files", label: "파일 관리", icon: FileText },
],
},
{
label: "분석 · 탐지",
items: [
{ key: "analysis-domain", label: "도메인 분석 관리", icon: ListChecks },
{ key: "analysis-file", label: "파일 분석 관리", icon: ListChecks },
{ key: "analysis-ip", label: "IP 분석 관리", icon: ListChecks },
{ key: "analysis-js", label: "JS 분석 관리", icon: ListChecks },
{ key: "analysis-risk-models", label: "위험도 모델 관리", icon: ListChecks },
],
},
{
label: "판정 · 검수",
items: [
{ key: "adj-compare", label: "판정 결과 비교", icon: Database },
{ key: "adj-mismatch", label: "판정 상이 관리", icon: Database },
{ key: "adj-fp-suspects", label: "오탐 의심 관리", icon: Database },
{ key: "adj-fp-final-edits", label: "오탐(Final) 수정 기록", icon: Database },
{ key: "adj-fp-reports", label: "오탐 신고 관리", icon: Database },
{ key: "adj-phishing", label: "피싱 관리", icon: Database },
],
},
{
label: "인텔리전스 DB",
items: [
{ key: "intel-url-db", label: "URL DB 관리", icon: Database },
{ key: "intel-domain-db", label: "도메인 DB 관리", icon: Database },
{ key: "intel-bkb-db", label: "BKB DB 관리", icon: Database },
],
},
{
label: "서비스 운영",
items: [
{ key: "ops-users", label: "사용자 관리", icon: Users },
{ key: "ops-admins", label: "관리자 관리", icon: Users },
{ key: "ops-initial-passwords", label: "초기 비밀번호 관리", icon: Users },
{ key: "ops-billing", label: "과금 관리", icon: Users },
{ key: "ops-bookmarks", label: "북마크 관리", icon: Users },
{ key: "ops-search-history", label: "검색 기록 관리", icon: Users },
{ key: "ops-login-stats", label: "로그인 통계", icon: Users },
{ key: "ops-search-stats", label: "사용자 검색 기록 통계", icon: Users },
],
},
{
label: "접근 기록 · 감사",
items: [
{ key: "audit-user-access", label: "사용자 접근이력 로그", icon: Shield },
{ key: "audit-admin-access", label: "관리자 접근이력 로그", icon: Shield },
{ key: "audit-user-auth", label: "사용자 로그인/로그아웃 로그", icon: Shield },
{ key: "audit-admin-auth", label: "관리자 로그인/로그아웃 로그", icon: Shield },
{ key: "audit-system-ops", label: "시스템 운영 로그", icon: Shield },
],
},
];
export function findLabelByKey(key: RouteKey){
for (const g of menuGroups) {
const f = g.items.find(i => i.key === key);
if (f) return f.label;
}
return undefined;
}
2) 그룹형 사이드바 컴포넌트
파일: src/components/layout/Sidebar.tsx
"use client";
import { menuGroups, type RouteKey } from "@/config/ia";
export default function Sidebar({
active,
onChange,
}: { active: RouteKey; onChange: (k: RouteKey)=>void }) {
return (
<nav className="sticky top-[60px] space-y-4 rounded-2xl border bg-white p-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
{menuGroups.map((g) => (
<div key={g.label} className="space-y-1">
<div className="px-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-neutral-400">
{g.label}
</div>
{g.items.map((m) => (
<button
key={m.key}
onClick={() => onChange(m.key)}
aria-current={active === m.key ? "page" : undefined}
className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-100 dark:hover:bg-neutral-800
${active===m.key ? "bg-gray-100 font-medium dark:bg-neutral-800" : ""}`}
>
<m.icon className="h-4 w-4" />
<span>{m.label}</span>
</button>
))}
</div>
))}
</nav>
);
}
접근성 팁: 현재 항목에
aria-current="page"를 달아 스크린리더가 인식할 수 있게 했습니다.
3) 페이지 템플릿 4종
파일: src/components/pages/templates.tsx
"use client";
import React, { useMemo, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CalendarDays, Download, Filter, Search } from "lucide-react";
import { ResponsiveContainer, LineChart, CartesianGrid, XAxis, YAxis, Tooltip, Line, BarChart, Bar } from "recharts";
// 1) 대시보드(차트 + KPI)
export function DashboardPage(){
const kpis = [
{ label:"Active Users", value:"12,391", diff:+6.2 },
{ label:"New Signups", value:"1,204", diff:+3.1 },
{ label:"Error Rate", value:"0.18%", diff:-0.05 },
{ label:"Revenue", value:"₩48.2M", diff:+12.4 },
];
const data = Array.from({length:12}, (_,i)=>({ m:`M${i+1}`, users: Math.round(800+Math.random()*600) }));
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{kpis.map(k => (
<Card key={k.label} className="dark:border-neutral-800 dark:bg-neutral-900">
<CardHeader className="pb-2">
<CardDescription>{k.label}</CardDescription>
<CardTitle className="text-2xl">{k.value}</CardTitle>
</CardHeader>
<CardContent>
<Badge variant={k.diff>=0?"default":"destructive"}>{k.diff>=0?"▲":"▼"}{Math.abs(k.diff)}%</Badge>
</CardContent>
</Card>
))}
</div>
<Card className="dark:border-neutral-800 dark:bg-neutral-900">
<CardHeader>
<CardTitle>User Growth</CardTitle>
<CardDescription>Last 12 months</CardDescription>
</CardHeader>
<CardContent className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="m" /><YAxis /><Tooltip />
<Line type="monotone" dataKey="users" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}
// 2) 제너릭 테이블
export function GenericTablePage({ title }: { title: string }){
const rows = Array.from({length:8}, (_,i)=>({ id: i+1, name:`Item ${i+1}`, status: i%3?"Active":"Pending" }));
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{title}</h2>
<div className="flex items-center gap-2">
<Button variant="outline"><Filter className="mr-2 h-4 w-4"/>Filter</Button>
<Button><Download className="mr-2 h-4 w-4"/>Export</Button>
</div>
</div>
<div className="overflow-hidden rounded-2xl border dark:border-neutral-800">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600 dark:bg-neutral-900 dark:text-neutral-400">
<tr>
<th className="px-4 py-3 text-left">ID</th>
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-left">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r,i)=> (
<tr key={r.id} className={i%2?"bg-white/60 dark:bg-neutral-950/50":"bg-white dark:bg-neutral-950"}>
<td className="px-4 py-3 font-mono">{r.id}</td>
<td className="px-4 py-3">{r.name}</td>
<td className="px-4 py-3">{r.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// 3) 로그 테이블
export function LogsPage({ title }: { title: string }){
const logs = Array.from({length:24}, (_,i)=>({
id: i+1,
at: new Date(Date.now()-i*3600_000).toISOString().replace('T',' ').slice(0,16),
who: i%3?"system":"admin@naver.how",
what: i%4?"Updated policy mapping":"Resolved mismatch",
}));
return (
<div className="space-y-3">
<h2 className="text-xl font-semibold">{title}</h2>
<div className="overflow-hidden rounded-2xl border dark:border-neutral-800">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600 dark:bg-neutral-900 dark:text-neutral-400">
<tr>
<th className="px-4 py-3 text-left">Time</th>
<th className="px-4 py-3 text-left">Actor</th>
<th className="px-4 py-3 text-left">Action</th>
</tr>
</thead>
<tbody>
{logs.map((l,i)=> (
<tr key={l.id} className={i%2?"bg-white/60 dark:bg-neutral-950/50":"bg-white dark:bg-neutral-950"}>
<td className="px-4 py-3 font-mono">{l.at}</td>
<td className="px-4 py-3">{l.who}</td>
<td className="px-4 py-3">{l.what}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// 4) 시트(Sheet) 포함된 “판정 상이 관리” 예시
export function MismatchPage(){
const [query, setQuery] = useState("");
const rows = useMemo(()=>[
{ id:"mm-001", platform:"Olpemi", type:"SKU Mismatch", src:"ExtVendor A", detected:"2025-10-02", severity:"High", status:"Open" },
{ id:"mm-002", platform:"Olpemi", type:"Policy Drift", src:"Service B", detected:"2025-10-06", severity:"Medium", status:"Investigating" },
{ id:"mm-003", platform:"Olpemi", type:"Attribute Gap", src:"ExtVendor C", detected:"2025-10-08", severity:"Low", status:"Resolved" },
].filter(r => (r.id+r.type+r.platform+r.src).toLowerCase().includes(query.toLowerCase())), [query]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-xl font-semibold">판정 상이 관리</h2>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-xl border px-3 py-1.5 dark:border-neutral-800">
<Search className="h-4 w-4 opacity-60" />
<input className="w-48 bg-transparent text-sm outline-none" placeholder="Search…" value={query} onChange={(e)=>setQuery(e.target.value)} />
</div>
<Select>
<SelectTrigger className="w-[160px]"><SelectValue placeholder="Severity" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
<Button variant="outline"><CalendarDays className="mr-2 h-4 w-4"/>Range</Button>
</div>
</div>
<div className="overflow-hidden rounded-2xl border dark:border-neutral-800">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600 dark:bg-neutral-900 dark:text-neutral-400">
<tr>
<th className="px-4 py-3 text-left">ID</th>
<th className="px-4 py-3 text-left">Platform</th>
<th className="px-4 py-3 text-left">Type</th>
<th className="px-4 py-3 text-left">Source</th>
<th className="px-4 py-3 text-left">Detected</th>
<th className="px-4 py-3 text-left">Severity</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((r,i)=> (
<tr key={r.id} className={i%2?"bg-white/60 dark:bg-neutral-950/50":"bg-white dark:bg-neutral-950"}>
<td className="px-4 py-3 font-mono">{r.id}</td>
<td className="px-4 py-3">{r.platform}</td>
<td className="px-4 py-3">{r.type}</td>
<td className="px-4 py-3">{r.src}</td>
<td className="px-4 py-3">{r.detected}</td>
<td className="px-4 py-3">{pill(r.severity)}</td>
<td className="px-4 py-3">{pill(r.status)}</td>
<td className="px-4 py-3 text-right">
<Sheet>
<SheetTrigger asChild><Button size="sm">Resolve</Button></SheetTrigger>
<SheetContent className="w-[520px] sm:max-w-none">
<SheetHeader>
<SheetTitle>Resolve {r.id}</SheetTitle>
<SheetDescription>상태 업데이트 & 감사 메모 기록</SheetDescription>
</SheetHeader>
<div className="mt-4 space-y-4">
<div className="grid grid-cols-3 gap-2 text-sm">
<Label>Type</Label><div className="col-span-2">{r.type}</div>
<Label>Source</Label><div className="col-span-2">{r.src}</div>
<Label>Severity</Label><div className="col-span-2">{pill(r.severity)}</div>
</div>
<div>
<Label>Resolution</Label>
<Input placeholder="e.g. Mapped vendor SKU to master catalog" className="mt-1"/>
</div>
<div>
<Label>Assignee</Label>
<Input placeholder="Type an email or team" className="mt-1"/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button>Save</Button>
</div>
</div>
</SheetContent>
</Sheet>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function pill(s: string){
const map: Record<string, string> = {
High: "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300",
Medium: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
Low: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
Open: "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300",
Investigating: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
Resolved: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
Pending: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
Active: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
};
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs ${map[s]||"bg-gray-100 text-gray-700 dark:bg-neutral-800 dark:text-neutral-300"}`}>{s}</span>;
}
4) 상단바 + 레이아웃(쉘) 구성
파일: src/components/layout/AdminShell.tsx
"use client";
import { useState } from "react";
import Sidebar from "./Sidebar";
import { type RouteKey, findLabelByKey } from "@/config/ia";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronDown, Menu, Moon, Sun } from "lucide-react";
import { DashboardPage, GenericTablePage, LogsPage, MismatchPage } from "@/components/pages/templates";
export default function AdminShell(){
const [dark, setDark] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const [active, setActive] = useState<RouteKey>("dashboard");
return (
<div className={dark ? "dark" : ""}>
<div className="min-h-screen bg-gray-50 text-gray-900 transition-colors dark:bg-neutral-950 dark:text-neutral-100">
{/* Top bar */}
<header className="sticky top-0 z-40 border-b bg-white/70 backdrop-blur supports-[backdrop-filter]:bg-white/60 dark:border-neutral-800 dark:bg-neutral-950/60">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="md:hidden" onClick={() => setCollapsed((c) => !c)}>
<Menu className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<div className="h-7 w-7 rounded-xl bg-[var(--brand)] [background:linear-gradient(135deg,var(--brand),var(--brand-2))]" />
<span className="font-semibold tracking-tight">naver.how admin</span>
</div>
<Badge variant="secondary" className="ml-2 hidden md:inline-flex">Prototype</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => setDark((d) => !d)}>
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 dark:border-neutral-800">
<div className="h-6 w-6 rounded-full bg-[var(--brand-2)]" />
Admin <ChevronDown className="h-4 w-4 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">Sign out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
{/* Layout */}
<div className="mx-auto grid max-w-7xl grid-cols-12 gap-4 px-4 py-6">
{/* Sidebar */}
<aside className={`col-span-12 md:col-span-3 lg:col-span-2 ${collapsed ? "hidden md:block" : "block"}`}>
<Sidebar active={active} onChange={setActive} />
<Card className="mt-4 dark:border-neutral-800 dark:bg-neutral-900">
<CardHeader>
<CardTitle className="text-base">Quick Links</CardTitle>
<CardDescription>Common admin actions</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Button variant="secondary" className="justify-start">Create user</Button>
<Button variant="secondary" className="justify-start">New report</Button>
<Button variant="secondary" className="justify-start">Export CSV</Button>
</CardContent>
</Card>
</aside>
{/* Content */}
<main className="col-span-12 md:col-span-9 lg:col-span-10">
{renderPage(active)}
</main>
</div>
</div>
</div>
);
}
function renderPage(k: RouteKey){
const label = findLabelByKey(k) || k;
switch (k) {
case "dashboard": return <DashboardPage/>;
case "adj-mismatch": return <MismatchPage/>;
case "ops-login-stats":
case "ops-search-stats":
case "analysis-risk-models":
case "ingest-crawl-logs":
case "audit-user-access":
case "audit-admin-access":
case "audit-user-auth":
case "audit-admin-auth":
case "audit-system-ops":
return <LogsPage title={label} />;
default:
return <GenericTablePage title={label} />;
}
}
5) 홈에서 레이아웃 렌더링
파일: src/app/page.tsx
import AdminShell from "@/components/layout/AdminShell";
export default function Page(){ return <AdminShell />; }
클라이언트 컴포넌트 파일들(
Sidebar,AdminShell,templates)은 각각 맨 위에"use client"선언이 들어 있어야 합니다(위 예시 포함).
6) 실행 (포트 7401)
pnpm approve-builds # 처음 한 번만
pnpm dev -- -H 0.0.0.0 -p 7401
# 또는
pnpm build && pnpm start -p 7401
브라우저에서 좌측 그룹형 사이드바와 본문 템플릿(대시보드/테이블/로그/시트)이 정상 동작하면 성공!
7) 문제 해결 체크리스트
- 레이아웃이 깨짐(글자만 보임) →
src/app/globals.css임포트, PostCSS 설정,pnpm approve-builds확인 - 아이콘 타입 에러 →
any대신LucideIcon타입 사용(위ia.ts참고) - recharts 렌더링 경고 → 해당 파일 맨 위에
"use client"필수 - 서브패스 프록시(nginx)와 정적파일 → 시리즈 5/6에서 Nginx 설정을 정리합니다
마무리
이제 그룹형 IA로 메뉴가 정리됐고, 클릭 시 바로 쓸 수 있는 4가지 페이지 템플릿이 붙었습니다. 다음 편에서는 데이터 연동(가짜 API → 실제 API 스위칭), 환경변수(.env)와 안전한 비밀 관리, 폼/검증, 목록 페이징/검색/정렬을 다룹니다.
답글 남기기