title: content-orchestration UI/UX 구현 플랜 (L2) date: 2026-02-25T22:15:00+09:00 type: implementation-plan layer: L2 status: draft tags: [content-orchestration, uiux, dashboard, L2, phase1] author: uiux-impl-pl project: content-orchestration reviewed_by: "jarvis" reviewed_at: "2026-02-25T21:55:00+09:00" approved_by: "" approved_at: ""
content-orchestration UI/UX 구현 플랜 (L2)
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: CEO가 /pipeline 대시보드에서 콘텐츠를 검수(승인/거부)하고, 파이프라인 실행 이력과 에러 현황을 한눈에 볼 수 있는 관리 UI를 구현한다.
Architecture: Next.js 15 App Router의 중첩 레이아웃을 활용하여 /pipeline 하위에 별도 관리자 레이아웃(사이드바 네비게이션)을 적용한다. 기존 블로그 페이지(/, /posts/[slug])는 절대 수정하지 않는다. 5개 신규 API + 4개 페이지 + 12개 컴포넌트를 구현한다. 모든 데이터는 기존 Turso DB(content-os)에서 조회하며, 기존 approve/reject API를 그대로 호출한다.
Tech Stack: Next.js 15 (App Router), TypeScript, Tailwind CSS v4, @libsql/client/web (Turso), react-markdown + remark-gfm
근거 문서:
- L1 UI/UX 설계서:
content-orchestration-design-uiux.md(approved by musk-vp) - L2 Phase 1 백엔드 구현 플랜:
content-orchestration-impl-phase1.md(approved) - 기존 DB 모델:
src/lib/content-db.ts - 기존 API:
src/app/api/pipeline/{content,approve,reject}/route.ts
프로젝트 경로: /Users/nbs22/(Claude)/(claude).projects/business-builder/projects/content-pipeline/
GitHub: migkjy/ai-blog
기존 코드 보호 규칙 (절대 준수)
이하 파일은 절대 수정 금지:
src/app/page.tsx(블로그 홈)src/app/posts/[slug]/page.tsx(포스트 페이지)src/app/feed.xml/route.ts(RSS)src/app/og/route.tsx(OG 이미지)src/app/api/pipeline/content/route.ts(기존 콘텐츠 목록 API)src/app/api/pipeline/approve/route.ts(기존 승인 API)src/app/api/pipeline/reject/route.ts(기존 거부 API)src/styles/global.css(기존 CSS — 추가만 가능, 기존 규칙 수정 금지)
전체 구현 범위 요약
| # | Task | 산출물 | 의존성 |
|---|---|---|---|
| 1 | 공용 컴포넌트: status-badge, stat-card, toast | 3개 컴포넌트 | 없음 |
| 2 | 공용 컴포넌트: filter-bar, pagination | 2개 컴포넌트 | 없음 |
| 3 | API: GET /api/pipeline/stats | 통계 API | 없음 |
| 4 | API: GET /api/pipeline/content/[id] | 상세 조회 API | 없음 |
| 5 | API: GET /api/pipeline/logs | 로그 목록 API | 없음 |
| 6 | API: GET /api/pipeline/errors + POST errors/[id]/resolve | 에러 API 2개 | 없음 |
| 7 | pipeline layout + sidebar | 레이아웃 + 사이드바 | 없음 |
| 8 | pipeline 홈 페이지 (/pipeline) | 대시보드 홈 | Task 1, 3, 7 |
| 9 | 콘텐츠 검수 페이지 (/pipeline/review) — 핵심 | 승인/거부 UI | Task 1, 2, 4, 7 |
| 10 | 실행 이력 페이지 (/pipeline/logs) | 로그 테이블 | Task 1, 2, 5, 7 |
| 11 | 에러 현황 페이지 (/pipeline/errors) | 에러 대시보드 | Task 1, 2, 6, 7 |
| 12 | E2E 수동 테스트 + 빌드 확인 | 빌드 성공 | Task 8-11 |
Task 1: 공용 컴포넌트 — status-badge, stat-card, toast
Files:
- Create:
src/components/pipeline/status-badge.tsx - Create:
src/components/pipeline/stat-card.tsx - Create:
src/components/pipeline/toast.tsx
Step 1: Create status-badge component
src/components/pipeline/status-badge.tsx:
'use client';
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
draft: { label: '초안', color: 'text-gray-700', bg: 'bg-gray-100' },
reviewing: { label: '검수중', color: 'text-yellow-700', bg: 'bg-yellow-100' },
approved: { label: '승인됨', color: 'text-green-700', bg: 'bg-green-100' },
scheduled: { label: '예약됨', color: 'text-blue-700', bg: 'bg-blue-100' },
published: { label: '발행됨', color: 'text-indigo-700', bg: 'bg-indigo-100' },
failed: { label: '실패', color: 'text-red-700', bg: 'bg-red-100' },
// pipeline_logs status
started: { label: '진행중', color: 'text-blue-700', bg: 'bg-blue-100' },
completed: { label: '완료', color: 'text-green-700', bg: 'bg-green-100' },
};
export default function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || { label: status, color: 'text-gray-700', bg: 'bg-gray-100' };
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.color} ${config.bg}`}>
{config.label}
</span>
);
}
Step 2: Create stat-card component
src/components/pipeline/stat-card.tsx:
'use client';
import Link from 'next/link';
interface StatCardProps {
label: string;
value: number | string;
href?: string;
color?: 'blue' | 'green' | 'yellow' | 'red';
}
const COLOR_MAP = {
blue: 'border-blue-200 bg-blue-50',
green: 'border-green-200 bg-green-50',
yellow: 'border-yellow-200 bg-yellow-50',
red: 'border-red-200 bg-red-50',
};
export default function StatCard({ label, value, href, color = 'blue' }: StatCardProps) {
const content = (
<div className={`rounded-xl border p-4 ${COLOR_MAP[color]} ${href ? 'hover:shadow-md transition-shadow cursor-pointer' : ''}`}>
<p className="text-sm text-[var(--color-text-muted)] mb-1">{label}</p>
<p className="text-3xl font-bold text-[var(--color-text)]">{value}</p>
</div>
);
if (href) {
return <Link href={href}>{content}</Link>;
}
return content;
}
Step 3: Create toast component
src/components/pipeline/toast.tsx:
'use client';
import { useEffect, useState } from 'react';
export interface ToastMessage {
id: string;
type: 'success' | 'error' | 'info';
message: string;
}
const TYPE_STYLES = {
success: 'bg-green-50 border-green-300 text-green-800',
error: 'bg-red-50 border-red-300 text-red-800',
info: 'bg-blue-50 border-blue-300 text-blue-800',
};
export function Toast({ toast, onRemove }: { toast: ToastMessage; onRemove: (id: string) => void }) {
useEffect(() => {
const timer = setTimeout(() => onRemove(toast.id), 4000);
return () => clearTimeout(timer);
}, [toast.id, onRemove]);
return (
<div className={`border rounded-lg px-4 py-3 text-sm shadow-lg ${TYPE_STYLES[toast.type]}`}>
{toast.message}
</div>
);
}
export function ToastContainer({ toasts, onRemove }: { toasts: ToastMessage[]; onRemove: (id: string) => void }) {
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map((t) => (
<Toast key={t.id} toast={t} onRemove={onRemove} />
))}
</div>
);
}
export function useToast() {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const addToast = (type: ToastMessage['type'], message: string) => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, type, message }]);
};
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
return { toasts, addToast, removeToast };
}
Step 4: Verify build compiles
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && npx tsc --noEmit --pretty 2>&1 | head -20
Expected: No errors related to the new files
Step 5: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/components/pipeline/status-badge.tsx src/components/pipeline/stat-card.tsx src/components/pipeline/toast.tsx
git commit -m "feat(pipeline): add shared UI components — status-badge, stat-card, toast"
Task 2: 공용 컴포넌트 — filter-bar, pagination
Files:
- Create:
src/components/pipeline/filter-bar.tsx - Create:
src/components/pipeline/pagination.tsx
Step 1: Create filter-bar component
src/components/pipeline/filter-bar.tsx:
'use client';
interface FilterOption {
label: string;
value: string;
count?: number;
}
interface FilterBarProps {
options: FilterOption[];
value: string;
onChange: (value: string) => void;
}
export default function FilterBar({ options, value, onChange }: FilterBarProps) {
return (
<div className="flex flex-wrap items-center gap-2">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
value === opt.value
? 'bg-[var(--color-primary)] text-white border-[var(--color-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
}`}
>
{opt.label}
{opt.count !== undefined && (
<span className="ml-1 text-xs opacity-70">{opt.count}</span>
)}
</button>
))}
</div>
);
}
Step 2: Create pagination component
src/components/pipeline/pagination.tsx:
'use client';
interface PaginationProps {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null;
const pages: number[] = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
pages.push(i);
}
}
// Insert ellipsis markers (represented as -1)
const withEllipsis: number[] = [];
for (let i = 0; i < pages.length; i++) {
if (i > 0 && pages[i] - pages[i - 1] > 1) {
withEllipsis.push(-1);
}
withEllipsis.push(pages[i]);
}
return (
<nav className="flex items-center justify-center gap-1.5 mt-6">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="px-3 py-2 text-sm rounded-lg border border-[var(--color-border)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
이전
</button>
{withEllipsis.map((p, idx) =>
p === -1 ? (
<span key={`ellipsis-${idx}`} className="px-2 text-sm text-[var(--color-text-muted)]">...</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
p === page
? 'bg-[var(--color-primary)] text-white font-semibold'
: 'border border-[var(--color-border)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
}`}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-3 py-2 text-sm rounded-lg border border-[var(--color-border)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
다음
</button>
</nav>
);
}
Step 3: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/components/pipeline/filter-bar.tsx src/components/pipeline/pagination.tsx
git commit -m "feat(pipeline): add filter-bar and pagination shared components"
Task 3: API — GET /api/pipeline/stats
Files:
- Create:
src/app/api/pipeline/stats/route.ts
Step 1: Implement stats API
src/app/api/pipeline/stats/route.ts:
import { createClient } from '@libsql/client/web';
import { NextResponse } from 'next/server';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
/**
* GET /api/pipeline/stats
*
* 파이프라인 홈 대시보드용 요약 통계.
* - collected_today: 오늘 수집된 뉴스 수 (collected_news)
* - pending_review: 검수 대기 콘텐츠 수 (content_queue status IN draft/reviewing)
* - published_today: 오늘 발행된 콘텐츠 수 (content_queue status=published)
* - unresolved_errors: 미해결 에러 수 (error_logs resolved_at IS NULL)
* - recent_logs: 최근 파이프라인 실행 5건
*/
export async function GET() {
try {
const db = getContentDb();
// 오늘 00:00 KST (UTC+9) 타임스탬프 (ms)
const now = new Date();
const kstOffset = 9 * 60 * 60 * 1000;
const kstNow = new Date(now.getTime() + kstOffset);
const todayStart = new Date(kstNow.getFullYear(), kstNow.getMonth(), kstNow.getDate());
const todayStartMs = todayStart.getTime() - kstOffset;
const [collectedRes, pendingRes, publishedRes, errorsRes, logsRes] = await Promise.all([
db.execute({
sql: 'SELECT COUNT(*) as cnt FROM collected_news WHERE created_at >= ?',
args: [todayStartMs],
}),
db.execute({
sql: "SELECT COUNT(*) as cnt FROM content_queue WHERE status IN ('draft', 'reviewing')",
args: [],
}),
db.execute({
sql: "SELECT COUNT(*) as cnt FROM content_queue WHERE status = 'published' AND updated_at >= ?",
args: [todayStartMs],
}),
db.execute({
sql: 'SELECT COUNT(*) as cnt FROM error_logs WHERE resolved_at IS NULL',
args: [],
}),
db.execute({
sql: 'SELECT id, pipeline_name, status, items_processed, duration_ms, created_at FROM pipeline_logs ORDER BY created_at DESC LIMIT 5',
args: [],
}),
]);
return NextResponse.json({
collected_today: Number((collectedRes.rows[0] as Record<string, unknown>).cnt) || 0,
pending_review: Number((pendingRes.rows[0] as Record<string, unknown>).cnt) || 0,
published_today: Number((publishedRes.rows[0] as Record<string, unknown>).cnt) || 0,
unresolved_errors: Number((errorsRes.rows[0] as Record<string, unknown>).cnt) || 0,
recent_logs: logsRes.rows,
});
} catch (err) {
console.error('[stats] Error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Step 2: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/app/api/pipeline/stats/route.ts
git commit -m "feat(pipeline): add GET /api/pipeline/stats for dashboard summary"
Task 4: API — GET /api/pipeline/content/[id]
Files:
- Create:
src/app/api/pipeline/content/[id]/route.ts
Step 1: Implement content detail API
src/app/api/pipeline/content/[id]/route.ts:
import { createClient } from '@libsql/client/web';
import { NextRequest, NextResponse } from 'next/server';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
/**
* GET /api/pipeline/content/[id]
*
* 개별 콘텐츠 상세 조회 (content_body 포함).
* 기존 GET /api/pipeline/content는 목록 조회이므로 content_body를 포함하지 않는다.
* 이 API는 승인 화면에서 콘텐츠 본문 미리보기를 위해 content_body를 함께 반환한다.
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const db = getContentDb();
const result = await db.execute({
sql: `SELECT id, type, pillar, topic, status, title, content_body,
approved_by, approved_at, rejected_reason, created_at, updated_at
FROM content_queue WHERE id = ? LIMIT 1`,
args: [id],
});
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Content not found' }, { status: 404 });
}
return NextResponse.json({ item: result.rows[0] });
} catch (err) {
console.error('[content/id] Error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Step 2: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/app/api/pipeline/content/\[id\]/route.ts
git commit -m "feat(pipeline): add GET /api/pipeline/content/[id] for detail view"
Task 5: API — GET /api/pipeline/logs
Files:
- Create:
src/app/api/pipeline/logs/route.ts
Step 1: Implement logs API
src/app/api/pipeline/logs/route.ts:
import { createClient } from '@libsql/client/web';
import { NextRequest, NextResponse } from 'next/server';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
/**
* GET /api/pipeline/logs?pipeline_name=&status=&days=7&page=1&limit=20
*
* 파이프라인 실행 로그 목록. 필터 + 페이지네이션 + 요약 통계.
*/
export async function GET(req: NextRequest) {
try {
const db = getContentDb();
const sp = req.nextUrl.searchParams;
const pipelineName = sp.get('pipeline_name');
const status = sp.get('status');
const days = Number(sp.get('days')) || 7;
const page = Math.max(1, Number(sp.get('page')) || 1);
const limit = Math.min(50, Math.max(1, Number(sp.get('limit')) || 20));
const offset = (page - 1) * limit;
// 기간 필터
const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
// WHERE 조건 빌드
const conditions: string[] = ['created_at >= ?'];
const args: (string | number)[] = [sinceMs];
if (pipelineName) {
conditions.push('pipeline_name = ?');
args.push(pipelineName);
}
if (status) {
conditions.push('status = ?');
args.push(status);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// 전체 개수
const countRes = await db.execute({
sql: `SELECT COUNT(*) as cnt FROM pipeline_logs ${whereClause}`,
args,
});
const total = Number((countRes.rows[0] as Record<string, unknown>).cnt) || 0;
// 목록 조회
const listRes = await db.execute({
sql: `SELECT id, pipeline_name, status, duration_ms, items_processed, error_message, metadata, trigger_type, created_at
FROM pipeline_logs ${whereClause}
ORDER BY created_at DESC LIMIT ? OFFSET ?`,
args: [...args, limit, offset],
});
// 요약 통계 (해당 기간 내)
const statsRes = await db.execute({
sql: `SELECT
COUNT(*) as total_runs,
ROUND(100.0 * SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) / MAX(COUNT(*), 1)) as success_rate,
ROUND(AVG(CASE WHEN status = 'completed' THEN duration_ms ELSE NULL END)) as avg_duration_ms
FROM pipeline_logs WHERE created_at >= ?`,
args: [sinceMs],
});
const statsRow = statsRes.rows[0] as Record<string, unknown>;
return NextResponse.json({
items: listRes.rows,
total,
page,
limit,
stats: {
total_runs: Number(statsRow.total_runs) || 0,
success_rate: Number(statsRow.success_rate) || 0,
avg_duration_ms: Number(statsRow.avg_duration_ms) || 0,
},
});
} catch (err) {
console.error('[logs] Error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Step 2: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/app/api/pipeline/logs/route.ts
git commit -m "feat(pipeline): add GET /api/pipeline/logs with filters and pagination"
Task 6: API — GET /api/pipeline/errors + POST /api/pipeline/errors/[id]/resolve
Files:
- Create:
src/app/api/pipeline/errors/route.ts - Create:
src/app/api/pipeline/errors/[id]/resolve/route.ts
Step 1: Implement errors list API
src/app/api/pipeline/errors/route.ts:
import { createClient } from '@libsql/client/web';
import { NextRequest, NextResponse } from 'next/server';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
/**
* GET /api/pipeline/errors?resolved=unresolved&component=&error_type=&page=1&limit=20
*
* 에러 로그 목록. 에스컬레이션 에러를 별도 배열로 분리.
*/
export async function GET(req: NextRequest) {
try {
const db = getContentDb();
const sp = req.nextUrl.searchParams;
const resolved = sp.get('resolved') || 'unresolved';
const component = sp.get('component');
const errorType = sp.get('error_type');
const page = Math.max(1, Number(sp.get('page')) || 1);
const limit = Math.min(50, Math.max(1, Number(sp.get('limit')) || 20));
const offset = (page - 1) * limit;
// WHERE 조건
const conditions: string[] = [];
const args: (string | number)[] = [];
if (resolved === 'unresolved') {
conditions.push('resolved_at IS NULL');
} else if (resolved === 'resolved') {
conditions.push('resolved_at IS NOT NULL');
}
// 'all'이면 resolved 조건 없음
if (component) {
conditions.push('component = ?');
args.push(component);
}
if (errorType) {
conditions.push('error_type = ?');
args.push(errorType);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// 에스컬레이션 에러 (미해결만)
const escalatedRes = await db.execute({
sql: `SELECT id, occurred_at, component, error_type, error_message, content_id, channel_id,
auto_fix_attempted, auto_fix_result, auto_fix_action, escalated, resolved_at, resolution_type
FROM error_logs WHERE escalated = 1 AND resolved_at IS NULL
ORDER BY occurred_at DESC LIMIT 10`,
args: [],
});
// 전체 개수 (에스컬레이션 제외 — 일반 에러만)
const nonEscConditions = [...conditions, 'escalated = 0'];
const nonEscWhere = nonEscConditions.length > 0 ? `WHERE ${nonEscConditions.join(' AND ')}` : '';
const countRes = await db.execute({
sql: `SELECT COUNT(*) as cnt FROM error_logs ${nonEscWhere}`,
args,
});
const total = Number((countRes.rows[0] as Record<string, unknown>).cnt) || 0;
// 일반 에러 목록
const listRes = await db.execute({
sql: `SELECT id, occurred_at, component, error_type, error_message, content_id, channel_id,
auto_fix_attempted, auto_fix_result, auto_fix_action, escalated, resolved_at, resolution_type
FROM error_logs ${nonEscWhere}
ORDER BY occurred_at DESC LIMIT ? OFFSET ?`,
args: [...args, limit, offset],
});
// 요약 통계
const statsRes = await db.execute({
sql: `SELECT
(SELECT COUNT(*) FROM error_logs WHERE resolved_at IS NULL) as unresolved,
(SELECT COUNT(*) FROM error_logs WHERE escalated = 1 AND resolved_at IS NULL) as escalated_count,
ROUND(100.0 * SUM(CASE WHEN auto_fix_result = 'success' THEN 1 ELSE 0 END) / MAX(SUM(CASE WHEN auto_fix_attempted = 1 THEN 1 ELSE 0 END), 1)) as auto_fix_success_rate
FROM error_logs WHERE occurred_at >= ?`,
args: [Date.now() - 7 * 24 * 60 * 60 * 1000],
});
const statsRow = statsRes.rows[0] as Record<string, unknown>;
return NextResponse.json({
escalated: escalatedRes.rows,
items: listRes.rows,
total,
page,
limit,
stats: {
unresolved: Number(statsRow.unresolved) || 0,
escalated_count: Number(statsRow.escalated_count) || 0,
auto_fix_success_rate: Number(statsRow.auto_fix_success_rate) || 0,
},
});
} catch (err) {
console.error('[errors] Error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Step 2: Implement error resolve API
src/app/api/pipeline/errors/[id]/resolve/route.ts:
import { createClient } from '@libsql/client/web';
import { NextRequest, NextResponse } from 'next/server';
function getContentDb() {
return createClient({
url: process.env.CONTENT_OS_DB_URL!,
authToken: process.env.CONTENT_OS_DB_TOKEN!,
});
}
/**
* POST /api/pipeline/errors/[id]/resolve
* Body: { resolution_type?: string }
*
* 에러를 수동 해결 처리한다.
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await req.json();
const resolutionType = body.resolution_type || 'manual_fixed';
const now = Date.now();
const db = getContentDb();
// 존재 확인
const existing = await db.execute({
sql: 'SELECT id, resolved_at FROM error_logs WHERE id = ?',
args: [id],
});
if (existing.rows.length === 0) {
return NextResponse.json({ error: 'Error not found' }, { status: 404 });
}
if ((existing.rows[0] as Record<string, unknown>).resolved_at) {
return NextResponse.json({ error: 'Already resolved' }, { status: 400 });
}
await db.execute({
sql: 'UPDATE error_logs SET resolved_at = ?, resolution_type = ? WHERE id = ?',
args: [now, resolutionType, id],
});
return NextResponse.json({ success: true, id, resolved_at: now, resolution_type: resolutionType });
} catch (err) {
console.error('[errors/resolve] Error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Step 3: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/app/api/pipeline/errors/route.ts src/app/api/pipeline/errors/\[id\]/resolve/route.ts
git commit -m "feat(pipeline): add GET /api/pipeline/errors and POST errors/[id]/resolve"
Task 7: pipeline layout + sidebar
Files:
- Create:
src/app/pipeline/layout.tsx - Create:
src/components/pipeline/sidebar.tsx
Step 1: Create sidebar component
src/components/pipeline/sidebar.tsx:
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const NAV_ITEMS = [
{ href: '/pipeline', label: '홈', icon: '□' },
{ href: '/pipeline/review', label: '콘텐츠 검수', icon: '✎' },
{ href: '/pipeline/logs', label: '실행 이력', icon: '≡' },
{ href: '/pipeline/errors', label: '에러 현황', icon: '!' },
];
export default function Sidebar({ unresolvedErrors }: { unresolvedErrors?: number }) {
const pathname = usePathname();
return (
<aside className="w-[240px] shrink-0 border-r border-[var(--color-border)] bg-[#F9FAFB] min-h-[calc(100vh-57px)]">
<nav className="p-4 flex flex-col gap-1">
<p className="text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wider mb-3 px-3">
Pipeline
</p>
{NAV_ITEMS.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors ${
isActive
? 'bg-[var(--color-primary-light)] text-[var(--color-primary)] font-semibold'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-gray-100'
}`}
>
<span className="w-5 text-center">{item.icon}</span>
<span>{item.label}</span>
{item.href === '/pipeline/errors' && unresolvedErrors && unresolvedErrors > 0 ? (
<span className="ml-auto inline-flex items-center justify-center w-5 h-5 text-xs font-bold bg-red-500 text-white rounded-full">
{unresolvedErrors > 9 ? '9+' : unresolvedErrors}
</span>
) : null}
</Link>
);
})}
</nav>
</aside>
);
}
Step 2: Create pipeline layout
src/app/pipeline/layout.tsx:
import Sidebar from '@/components/pipeline/sidebar';
import Link from 'next/link';
export const metadata = {
title: 'Pipeline Dashboard',
};
export default function PipelineLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{/* Override the blog header with a pipeline-specific header */}
<style>{`
/* Hide the blog header/footer when inside pipeline layout.
The pipeline layout provides its own header. */
body > header { display: none !important; }
body > footer { display: none !important; }
`}</style>
<div className="min-h-screen flex flex-col">
{/* Pipeline header */}
<header className="border-b border-[var(--color-border)] bg-white sticky top-0 z-40">
<div className="flex items-center justify-between px-6 py-3">
<div className="flex items-center gap-3">
<Link href="/pipeline" className="text-lg font-bold text-[var(--color-primary)]">
Pipeline Dashboard
</Link>
</div>
<Link
href="/"
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] transition-colors"
>
블로그로 이동 →
</Link>
</div>
</header>
{/* Body: sidebar + content */}
<div className="flex flex-1">
<Sidebar />
<main className="flex-1 p-6 bg-white overflow-auto">
{children}
</main>
</div>
</div>
</>
);
}
Step 3: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/app/pipeline/layout.tsx src/components/pipeline/sidebar.tsx
git commit -m "feat(pipeline): add pipeline layout with sidebar navigation"
Task 8: 파이프라인 홈 페이지 (/pipeline)
Files:
- Create:
src/app/pipeline/page.tsx
Depends on: Task 1 (stat-card, status-badge), Task 3 (/api/pipeline/stats), Task 7 (layout)
Step 1: Implement pipeline home page
src/app/pipeline/page.tsx:
'use client';
import { useEffect, useState } from 'react';
import StatCard from '@/components/pipeline/stat-card';
import StatusBadge from '@/components/pipeline/status-badge';
import Link from 'next/link';
interface Stats {
collected_today: number;
pending_review: number;
published_today: number;
unresolved_errors: number;
recent_logs: Array<{
id: string;
pipeline_name: string;
status: string;
items_processed: number;
duration_ms: number | null;
created_at: number;
}>;
}
interface ContentItem {
id: string;
title: string | null;
status: string;
pillar: string | null;
created_at: number;
}
const PIPELINE_NAMES: Record<string, string> = {
collect: '수집',
generate: '생성',
approve: '승인',
publish: '배포',
'self-healing': '자체교정',
};
function formatTime(ms: number): string {
const d = new Date(ms);
return d.toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function formatDuration(ms: number | null): string {
if (!ms) return '-';
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}초`;
}
export default function PipelineHomePage() {
const [stats, setStats] = useState<Stats | null>(null);
const [pendingItems, setPendingItems] = useState<ContentItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
async function fetchData() {
setLoading(true);
setError(null);
try {
const [statsRes, contentRes] = await Promise.all([
fetch('/api/pipeline/stats'),
fetch('/api/pipeline/content?status=reviewing'),
]);
if (!statsRes.ok || !contentRes.ok) throw new Error('API error');
const statsData = await statsRes.json();
const contentData = await contentRes.json();
setStats(statsData);
setPendingItems((contentData.items || []).slice(0, 3));
} catch {
setError('데이터를 불러올 수 없습니다.');
} finally {
setLoading(false);
}
}
useEffect(() => { fetchData(); }, []);
if (loading) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">파이프라인 홈</h1>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="rounded-xl border border-gray-200 bg-gray-50 p-4 animate-pulse h-20" />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="text-center py-16">
<p className="text-[var(--color-text-muted)] mb-4">{error}</p>
<button onClick={fetchData} className="text-sm text-[var(--color-primary)] hover:underline">
재시도
</button>
</div>
);
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">파이프라인 홈</h1>
<button onClick={fetchData} className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
새로고침
</button>
</div>
{/* 요약 카드 4개 */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="오늘 수집" value={stats?.collected_today ?? 0} color="blue" />
<StatCard label="검수 대기" value={stats?.pending_review ?? 0} color="yellow" href="/pipeline/review" />
<StatCard label="오늘 발행" value={stats?.published_today ?? 0} color="green" />
<StatCard label="미해결 에러" value={stats?.unresolved_errors ?? 0} color="red" href="/pipeline/errors" />
</div>
{/* 최근 파이프라인 실행 5건 */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">최근 파이프라인 실행</h2>
<Link href="/pipeline/logs" className="text-sm text-[var(--color-primary)] hover:underline">
전체 보기
</Link>
</div>
{stats?.recent_logs && stats.recent_logs.length > 0 ? (
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--color-tag-bg)]">
<tr>
<th className="text-left px-4 py-2 font-semibold">시각</th>
<th className="text-left px-4 py-2 font-semibold">단계</th>
<th className="text-left px-4 py-2 font-semibold">상태</th>
<th className="text-right px-4 py-2 font-semibold">건수</th>
<th className="text-right px-4 py-2 font-semibold">소요</th>
</tr>
</thead>
<tbody>
{stats.recent_logs.map((log) => (
<tr key={log.id} className="border-t border-[var(--color-border)]">
<td className="px-4 py-2 text-[var(--color-text-muted)]">{formatTime(log.created_at)}</td>
<td className="px-4 py-2">{PIPELINE_NAMES[log.pipeline_name] || log.pipeline_name}</td>
<td className="px-4 py-2"><StatusBadge status={log.status} /></td>
<td className="px-4 py-2 text-right">{log.items_processed}건</td>
<td className="px-4 py-2 text-right text-[var(--color-text-muted)]">{formatDuration(log.duration_ms)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-[var(--color-text-muted)] py-8 text-center border border-[var(--color-border)] rounded-lg">
아직 파이프라인이 실행되지 않았습니다.
</p>
)}
</section>
{/* 검수 대기 콘텐츠 미리보기 */}
<section>
<h2 className="text-lg font-semibold mb-3">검수 대기 콘텐츠</h2>
{pendingItems.length > 0 ? (
<div className="space-y-3">
{pendingItems.map((item) => (
<div key={item.id} className="border border-[var(--color-border)] rounded-lg p-4 flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<StatusBadge status={item.status} />
{item.pillar && (
<span className="text-xs text-[var(--color-primary)] bg-[var(--color-primary-light)] px-2 py-0.5 rounded-full">
{item.pillar}
</span>
)}
</div>
<p className="text-sm font-medium truncate">{item.title || '(제목 없음)'}</p>
<p className="text-xs text-[var(--color-text-muted)]">{formatTime(item.created_at)}</p>
</div>
<Link
href="/pipeline/review"
className="ml-4 shrink-0 text-sm text-[var(--color-primary)] hover:underline"
>
검수하기 →
</Link>
</div>
))}
</div>
) : (
<p className="text-sm text-[var(--color-text-muted)] py-8 text-center border border-[var(--color-border)] rounded-lg">
검수 대기 중인 콘텐츠가 없습니다.
</p>
)}
</section>
</div>
);
}
Step 2: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/app/pipeline/page.tsx
git commit -m "feat(pipeline): add pipeline home dashboard with stats, logs, and review preview"
Task 9: 콘텐츠 검수 페이지 (/pipeline/review) -- 핵심
Files:
- Create:
src/app/pipeline/review/page.tsx - Create:
src/components/pipeline/content-list.tsx - Create:
src/components/pipeline/content-preview.tsx - Create:
src/components/pipeline/approve-actions.tsx
Depends on: Task 1, Task 2, Task 4, Task 7
Step 1: Create content-list component
src/components/pipeline/content-list.tsx:
'use client';
import StatusBadge from './status-badge';
export interface ContentListItem {
id: string;
title: string | null;
status: string;
pillar: string | null;
created_at: number;
rejected_reason: string | null;
}
interface ContentListProps {
items: ContentListItem[];
selectedId: string | null;
onSelect: (id: string) => void;
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleString('ko-KR', {
year: 'numeric', month: 'numeric', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
export default function ContentList({ items, selectedId, onSelect }: ContentListProps) {
if (items.length === 0) {
return (
<div className="text-sm text-[var(--color-text-muted)] py-12 text-center">
검수할 콘텐츠가 없습니다.<br />
파이프라인이 실행되면 여기에 표시됩니다.
</div>
);
}
return (
<div className="flex flex-col gap-1">
{items.map((item) => (
<button
key={item.id}
onClick={() => onSelect(item.id)}
className={`text-left p-3 rounded-lg border transition-colors ${
selectedId === item.id
? 'border-[var(--color-primary)] bg-[var(--color-primary-light)]'
: 'border-[var(--color-border)] hover:border-gray-300'
}`}
>
<div className="flex items-center gap-2 mb-1">
<StatusBadge status={item.status} />
{item.pillar && (
<span className="text-xs text-[var(--color-primary)] bg-[var(--color-primary-light)] px-2 py-0.5 rounded-full">
{item.pillar}
</span>
)}
</div>
<p className="text-sm font-medium line-clamp-2 leading-snug">
{item.title || '(제목 없음)'}
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
{formatDate(item.created_at)}
</p>
{item.rejected_reason && (
<p className="text-xs text-red-500 mt-1 truncate">
거부: {item.rejected_reason}
</p>
)}
</button>
))}
</div>
);
}
Step 2: Create content-preview component
src/components/pipeline/content-preview.tsx:
'use client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import StatusBadge from './status-badge';
export interface ContentDetail {
id: string;
type: string;
pillar: string | null;
topic: string | null;
status: string;
title: string | null;
content_body: string | null;
approved_by: string | null;
approved_at: number | null;
rejected_reason: string | null;
created_at: number;
updated_at: number;
}
interface ContentPreviewProps {
content: ContentDetail | null;
loading: boolean;
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleString('ko-KR', {
year: 'numeric', month: 'numeric', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
export default function ContentPreview({ content, loading }: ContentPreviewProps) {
if (loading) {
return (
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-64 bg-gray-100 rounded" />
</div>
);
}
if (!content) {
return (
<div className="text-center py-20 text-[var(--color-text-muted)]">
좌측에서 콘텐츠를 선택하세요.
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Metadata */}
<div className="border-b border-[var(--color-border)] pb-4 mb-4 space-y-2">
<h2 className="text-xl font-bold">{content.title || '(제목 없음)'}</h2>
<div className="flex flex-wrap items-center gap-3 text-sm text-[var(--color-text-muted)]">
<StatusBadge status={content.status} />
{content.pillar && (
<span className="text-xs text-[var(--color-primary)] bg-[var(--color-primary-light)] px-2 py-0.5 rounded-full">
{content.pillar}
</span>
)}
<span>유형: {content.type || '-'}</span>
<span>생성일: {formatDate(content.created_at)}</span>
</div>
{content.approved_by && (
<p className="text-xs text-green-600">
승인: {content.approved_by} ({content.approved_at ? formatDate(content.approved_at) : '-'})
</p>
)}
{content.rejected_reason && (
<p className="text-xs text-red-500">
이전 거부 사유: {content.rejected_reason}
</p>
)}
</div>
{/* Body */}
<div className="flex-1 overflow-auto">
{content.content_body ? (
<article className="prose prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content.content_body}
</ReactMarkdown>
</article>
) : (
<p className="text-sm text-[var(--color-text-muted)] italic">본문이 없습니다.</p>
)}
</div>
</div>
);
}
Step 3: Create approve-actions component
src/components/pipeline/approve-actions.tsx:
'use client';
import { useState } from 'react';
interface ApproveActionsProps {
contentId: string;
status: string;
onSuccess: (action: 'approve' | 'reject', result: Record<string, unknown>) => void;
onError: (message: string) => void;
}
export default function ApproveActions({ contentId, status, onSuccess, onError }: ApproveActionsProps) {
const [loading, setLoading] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const canAct = ['draft', 'reviewing'].includes(status);
async function handleApprove() {
if (!confirm('이 콘텐츠를 승인하시겠습니까? 승인 시 자동으로 블로그에 발행됩니다.')) return;
setLoading(true);
try {
const res = await fetch('/api/pipeline/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentId, approvedBy: 'ceo' }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Approve failed');
onSuccess('approve', data);
} catch (err) {
onError(err instanceof Error ? err.message : '승인 처리 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}
async function handleReject() {
if (!rejectReason.trim()) return;
setLoading(true);
try {
const res = await fetch('/api/pipeline/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentId, reason: rejectReason.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Reject failed');
setShowRejectModal(false);
setRejectReason('');
onSuccess('reject', data);
} catch (err) {
onError(err instanceof Error ? err.message : '거부 처리 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}
if (!canAct) {
return (
<div className="border-t border-[var(--color-border)] pt-4 mt-4">
<p className="text-sm text-[var(--color-text-muted)]">
이 콘텐츠는 이미 {status === 'approved' ? '승인' : status === 'published' ? '발행' : '처리'}되었습니다.
</p>
</div>
);
}
return (
<div className="border-t border-[var(--color-border)] pt-4 mt-4">
<div className="flex items-center gap-3">
<button
onClick={handleApprove}
disabled={loading}
className="px-6 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? '처리중...' : '승인'}
</button>
<button
onClick={() => setShowRejectModal(true)}
disabled={loading}
className="px-6 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
거부
</button>
</div>
{/* 거부 사유 모달 */}
{showRejectModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
<h3 className="text-lg font-bold mb-3">거부 사유를 입력해주세요</h3>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="예: 본문이 너무 짧습니다. 소제목 2개 이상 추가 필요."
className="w-full border border-[var(--color-border)] rounded-lg p-3 text-sm h-28 resize-none focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
/>
<div className="flex justify-end gap-3 mt-4">
<button
onClick={() => { setShowRejectModal(false); setRejectReason(''); }}
className="px-4 py-2 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
취소
</button>
<button
onClick={handleReject}
disabled={loading || !rejectReason.trim()}
className="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '처리중...' : '거부 확인'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
Step 4: Create review page
src/app/pipeline/review/page.tsx:
'use client';
import { useEffect, useState, useCallback } from 'react';
import ContentList, { type ContentListItem } from '@/components/pipeline/content-list';
import ContentPreview, { type ContentDetail } from '@/components/pipeline/content-preview';
import ApproveActions from '@/components/pipeline/approve-actions';
import FilterBar from '@/components/pipeline/filter-bar';
import { ToastContainer, useToast } from '@/components/pipeline/toast';
type FilterValue = 'all' | 'pending' | 'approved' | 'rejected';
const FILTER_OPTIONS = [
{ label: '전체', value: 'all' },
{ label: '검수대기', value: 'pending' },
{ label: '승인됨', value: 'approved' },
{ label: '거부됨', value: 'rejected' },
];
export default function ReviewPage() {
const [items, setItems] = useState<ContentListItem[]>([]);
const [filter, setFilter] = useState<FilterValue>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<ContentDetail | null>(null);
const [listLoading, setListLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toasts, addToast, removeToast } = useToast();
// Fetch content list
const fetchList = useCallback(async () => {
setListLoading(true);
setError(null);
try {
const res = await fetch('/api/pipeline/content');
if (!res.ok) throw new Error('API error');
const data = await res.json();
setItems(data.items || []);
} catch {
setError('데이터를 불러올 수 없습니다.');
} finally {
setListLoading(false);
}
}, []);
// Fetch content detail
const fetchDetail = useCallback(async (id: string) => {
setDetailLoading(true);
try {
const res = await fetch(`/api/pipeline/content/${id}`);
if (!res.ok) throw new Error('API error');
const data = await res.json();
setDetail(data.item || null);
} catch {
addToast('error', '콘텐츠 상세를 불러올 수 없습니다.');
} finally {
setDetailLoading(false);
}
}, [addToast]);
useEffect(() => { fetchList(); }, [fetchList]);
useEffect(() => {
if (selectedId) fetchDetail(selectedId);
else setDetail(null);
}, [selectedId, fetchDetail]);
// Filter items
const filteredItems = items.filter((item) => {
if (filter === 'pending') return ['draft', 'reviewing'].includes(item.status);
if (filter === 'approved') return item.status === 'approved';
if (filter === 'rejected') return item.rejected_reason != null && item.status === 'draft';
return true;
});
// Update filter counts
const filterOptions = FILTER_OPTIONS.map((opt) => ({
...opt,
count:
opt.value === 'all'
? items.length
: opt.value === 'pending'
? items.filter((i) => ['draft', 'reviewing'].includes(i.status)).length
: opt.value === 'approved'
? items.filter((i) => i.status === 'approved').length
: items.filter((i) => i.rejected_reason != null && i.status === 'draft').length,
}));
function handleActionSuccess(action: 'approve' | 'reject', result: Record<string, unknown>) {
if (action === 'approve') {
const autoPublish = result.autoPublish as { success?: boolean } | undefined;
if (autoPublish?.success) {
addToast('success', '승인 완료! 블로그 발행이 시작되었습니다.');
} else {
addToast('success', '승인 완료! (자동 발행은 별도 확인 필요)');
}
} else {
addToast('info', '콘텐츠가 거부되었습니다. AI가 사유를 반영하여 재생성합니다.');
}
// Refresh data
fetchList();
if (selectedId) fetchDetail(selectedId);
}
function handleActionError(message: string) {
addToast('error', message);
}
if (error) {
return (
<div className="text-center py-16">
<p className="text-[var(--color-text-muted)] mb-4">{error}</p>
<button onClick={fetchList} className="text-sm text-[var(--color-primary)] hover:underline">
재시도
</button>
</div>
);
}
return (
<div className="h-[calc(100vh-57px-48px)]">
<ToastContainer toasts={toasts} onRemove={removeToast} />
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">콘텐츠 검수</h1>
<button onClick={fetchList} className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
새로고침
</button>
</div>
{/* Filter tabs */}
<div className="mb-4">
<FilterBar options={filterOptions} value={filter} onChange={(v) => setFilter(v as FilterValue)} />
</div>
{/* Two-column layout: list + preview */}
<div className="flex gap-4 h-[calc(100%-100px)]">
{/* Left: Content list */}
<div className="w-[360px] shrink-0 overflow-auto border border-[var(--color-border)] rounded-lg p-3">
{listLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-gray-100 rounded animate-pulse" />
))}
</div>
) : (
<ContentList items={filteredItems} selectedId={selectedId} onSelect={setSelectedId} />
)}
</div>
{/* Right: Preview */}
<div className="flex-1 overflow-auto border border-[var(--color-border)] rounded-lg p-4 flex flex-col">
<ContentPreview content={detail} loading={detailLoading} />
{detail && (
<ApproveActions
contentId={detail.id}
status={detail.status}
onSuccess={handleActionSuccess}
onError={handleActionError}
/>
)}
</div>
</div>
</div>
);
}
Step 5: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/components/pipeline/content-list.tsx src/components/pipeline/content-preview.tsx src/components/pipeline/approve-actions.tsx src/app/pipeline/review/page.tsx
git commit -m "feat(pipeline): add content review page with approve/reject UI"
Task 10: 실행 이력 페이지 (/pipeline/logs)
Files:
- Create:
src/app/pipeline/logs/page.tsx - Create:
src/components/pipeline/log-table.tsx
Depends on: Task 1, 2, 5, 7
Step 1: Create log-table component
src/components/pipeline/log-table.tsx:
'use client';
import StatusBadge from './status-badge';
export interface LogItem {
id: string;
pipeline_name: string;
status: string;
duration_ms: number | null;
items_processed: number;
error_message: string | null;
metadata: string | null;
trigger_type: string | null;
created_at: number;
}
const PIPELINE_NAMES: Record<string, string> = {
collect: '수집',
generate: '생성',
approve: '승인',
publish: '배포',
'self-healing': '자체교정',
};
function formatTime(ms: number): string {
return new Date(ms).toLocaleString('ko-KR', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
function formatDuration(ms: number | null): string {
if (!ms) return '-';
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}초`;
}
function extractMetadataPreview(metadata: string | null, pipelineName: string): string {
if (!metadata) return '';
try {
const data = JSON.parse(metadata);
switch (pipelineName) {
case 'collect':
return [
data.feeds_ok != null && `feeds:${data.feeds_ok}/${(data.feeds_ok || 0) + (data.feeds_fail || 0)}`,
data.filter_pass != null && `pass:${data.filter_pass}`,
].filter(Boolean).join(' ');
case 'generate':
return [data.pillar, data.qa_score != null && `qa:${data.qa_score}`].filter(Boolean).join(' ');
case 'approve':
return data.approved_by || data.action || '';
case 'publish':
return data.channels_ok != null ? `ch:${data.channels_ok}/${(data.channels_ok || 0) + (data.channels_fail || 0)}` : '';
default:
return '';
}
} catch {
return '';
}
}
export default function LogTable({ items }: { items: LogItem[] }) {
if (items.length === 0) {
return (
<p className="text-sm text-[var(--color-text-muted)] py-12 text-center border border-[var(--color-border)] rounded-lg">
아직 파이프라인이 실행되지 않았습니다.<br />
Cron이 매일 06:00(KST)에 자동 실행합니다.
</p>
);
}
return (
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--color-tag-bg)]">
<tr>
<th className="text-left px-4 py-2 font-semibold">시각</th>
<th className="text-left px-4 py-2 font-semibold">단계</th>
<th className="text-left px-4 py-2 font-semibold">상태</th>
<th className="text-right px-4 py-2 font-semibold">건수</th>
<th className="text-right px-4 py-2 font-semibold">소요</th>
<th className="text-left px-4 py-2 font-semibold">메타데이터</th>
</tr>
</thead>
<tbody>
{items.map((log) => (
<tr key={log.id} className="border-t border-[var(--color-border)] hover:bg-gray-50">
<td className="px-4 py-2 text-[var(--color-text-muted)]">{formatTime(log.created_at)}</td>
<td className="px-4 py-2">{PIPELINE_NAMES[log.pipeline_name] || log.pipeline_name}</td>
<td className="px-4 py-2"><StatusBadge status={log.status} /></td>
<td className="px-4 py-2 text-right">{log.items_processed}건</td>
<td className="px-4 py-2 text-right text-[var(--color-text-muted)]">{formatDuration(log.duration_ms)}</td>
<td className="px-4 py-2 text-xs text-[var(--color-text-muted)]">
{extractMetadataPreview(log.metadata, log.pipeline_name)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Step 2: Create logs page
src/app/pipeline/logs/page.tsx:
'use client';
import { useEffect, useState, useCallback } from 'react';
import StatCard from '@/components/pipeline/stat-card';
import FilterBar from '@/components/pipeline/filter-bar';
import Pagination from '@/components/pipeline/pagination';
import LogTable, { type LogItem } from '@/components/pipeline/log-table';
const PIPELINE_FILTERS = [
{ label: '전체', value: '' },
{ label: '수집', value: 'collect' },
{ label: '생성', value: 'generate' },
{ label: '승인', value: 'approve' },
{ label: '배포', value: 'publish' },
{ label: '자체교정', value: 'self-healing' },
];
const STATUS_FILTERS = [
{ label: '전체', value: '' },
{ label: '완료', value: 'completed' },
{ label: '실패', value: 'failed' },
{ label: '진행중', value: 'started' },
];
const PERIOD_FILTERS = [
{ label: '최근 7일', value: '7' },
{ label: '최근 30일', value: '30' },
{ label: '전체', value: '365' },
];
interface LogsResponse {
items: LogItem[];
total: number;
page: number;
limit: number;
stats: {
total_runs: number;
success_rate: number;
avg_duration_ms: number;
};
}
export default function LogsPage() {
const [data, setData] = useState<LogsResponse | null>(null);
const [pipelineFilter, setPipelineFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [periodFilter, setPeriodFilter] = useState('7');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchLogs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (pipelineFilter) params.set('pipeline_name', pipelineFilter);
if (statusFilter) params.set('status', statusFilter);
params.set('days', periodFilter);
params.set('page', String(page));
params.set('limit', '20');
const res = await fetch(`/api/pipeline/logs?${params}`);
if (!res.ok) throw new Error('API error');
setData(await res.json());
} catch {
setError('실행 이력을 불러올 수 없습니다.');
} finally {
setLoading(false);
}
}, [pipelineFilter, statusFilter, periodFilter, page]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
// Reset page when filters change
useEffect(() => { setPage(1); }, [pipelineFilter, statusFilter, periodFilter]);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
if (error) {
return (
<div className="text-center py-16">
<p className="text-[var(--color-text-muted)] mb-4">{error}</p>
<button onClick={fetchLogs} className="text-sm text-[var(--color-primary)] hover:underline">재시도</button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">실행 이력</h1>
<button onClick={fetchLogs} className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
새로고침
</button>
</div>
{/* Stats cards */}
<div className="grid grid-cols-3 gap-4">
<StatCard label="총 실행" value={data?.stats.total_runs ?? '-'} color="blue" />
<StatCard label="성공률" value={data ? `${data.stats.success_rate}%` : '-'} color="green" />
<StatCard
label="평균 소요"
value={data?.stats.avg_duration_ms ? `${(data.stats.avg_duration_ms / 1000).toFixed(1)}초` : '-'}
color="blue"
/>
</div>
{/* Filters */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--color-text-muted)] w-12">단계:</span>
<FilterBar options={PIPELINE_FILTERS} value={pipelineFilter} onChange={setPipelineFilter} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--color-text-muted)] w-12">상태:</span>
<FilterBar options={STATUS_FILTERS} value={statusFilter} onChange={setStatusFilter} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--color-text-muted)] w-12">기간:</span>
<FilterBar options={PERIOD_FILTERS} value={periodFilter} onChange={setPeriodFilter} />
</div>
</div>
{/* Table */}
{loading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 bg-gray-100 rounded" />
))}
</div>
) : (
<LogTable items={data?.items || []} />
)}
{/* Pagination */}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
);
}
Step 3: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/components/pipeline/log-table.tsx src/app/pipeline/logs/page.tsx
git commit -m "feat(pipeline): add pipeline logs page with filters, stats, and pagination"
Task 11: 에러 현황 페이지 (/pipeline/errors)
Files:
- Create:
src/app/pipeline/errors/page.tsx - Create:
src/components/pipeline/error-table.tsx - Create:
src/components/pipeline/error-escalated.tsx
Depends on: Task 1, 2, 6, 7
Step 1: Create error-escalated component
src/components/pipeline/error-escalated.tsx:
'use client';
export interface EscalatedError {
id: string;
occurred_at: number;
component: string;
error_type: string;
error_message: string;
auto_fix_attempted: number;
auto_fix_result: string | null;
}
const ACTION_SUGGESTIONS: Record<string, string> = {
auth_fail: 'API 키 갱신이 필요합니다. 환경 변수를 확인하세요.',
quality_fail: '프롬프트 검토가 필요합니다.',
timeout: '서비스 상태를 확인하세요.',
api_error: '외부 서비스 장애가 의심됩니다.',
};
function formatTime(ms: number): string {
return new Date(ms).toLocaleString('ko-KR', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
interface ErrorEscalatedProps {
errors: EscalatedError[];
onResolve: (id: string) => void;
resolving: string | null;
}
export default function ErrorEscalated({ errors, onResolve, resolving }: ErrorEscalatedProps) {
if (errors.length === 0) return null;
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold text-red-700">에스컬레이션</h2>
{errors.map((err) => (
<div key={err.id} className="border-2 border-red-300 bg-red-50 rounded-lg p-4">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-red-800">
ESCALATED: {err.error_message}
</p>
<p className="text-sm text-red-600 mt-1">
component: {err.component} | type: {err.error_type}
</p>
<p className="text-xs text-red-500 mt-1">
{formatTime(err.occurred_at)}
</p>
<p className="text-sm text-red-700 mt-2 font-medium">
조치필요: {ACTION_SUGGESTIONS[err.error_type] || '확인이 필요합니다.'}
</p>
</div>
<button
onClick={() => onResolve(err.id)}
disabled={resolving === err.id}
className="shrink-0 ml-4 px-3 py-1.5 text-xs bg-white border border-red-300 text-red-700 rounded-lg hover:bg-red-100 disabled:opacity-50"
>
{resolving === err.id ? '처리중...' : '해결됨 처리'}
</button>
</div>
</div>
))}
</section>
);
}
Step 2: Create error-table component
src/components/pipeline/error-table.tsx:
'use client';
export interface ErrorItem {
id: string;
occurred_at: number;
component: string;
error_type: string;
error_message: string;
auto_fix_attempted: number;
auto_fix_result: string | null;
auto_fix_action: string | null;
resolved_at: number | null;
resolution_type: string | null;
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleString('ko-KR', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
function AutoFixBadge({ item }: { item: ErrorItem }) {
if (!item.auto_fix_attempted) {
return <span className="text-xs text-gray-400">대기중</span>;
}
switch (item.auto_fix_result) {
case 'success':
return <span className="text-xs text-green-600 font-medium">자동교정 성공 {item.auto_fix_action ? `(${item.auto_fix_action})` : ''}</span>;
case 'failed':
return <span className="text-xs text-orange-600 font-medium">자동교정 실패</span>;
case 'skipped':
return <span className="text-xs text-gray-500">교정 미시도</span>;
default:
return <span className="text-xs text-gray-400">대기중</span>;
}
}
interface ErrorTableProps {
items: ErrorItem[];
onResolve: (id: string) => void;
resolving: string | null;
}
export default function ErrorTable({ items, onResolve, resolving }: ErrorTableProps) {
if (items.length === 0) {
return (
<div className="text-center py-12 border border-green-200 bg-green-50 rounded-lg">
<p className="text-sm text-green-700">에러가 없습니다. 파이프라인이 정상 동작 중입니다.</p>
</div>
);
}
return (
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--color-tag-bg)]">
<tr>
<th className="text-left px-4 py-2 font-semibold">시각</th>
<th className="text-left px-4 py-2 font-semibold">컴포넌트</th>
<th className="text-left px-4 py-2 font-semibold">유형</th>
<th className="text-left px-4 py-2 font-semibold">자동교정</th>
<th className="text-left px-4 py-2 font-semibold">메시지</th>
<th className="text-center px-4 py-2 font-semibold">액션</th>
</tr>
</thead>
<tbody>
{items.map((err) => (
<tr key={err.id} className="border-t border-[var(--color-border)] hover:bg-gray-50">
<td className="px-4 py-2 text-[var(--color-text-muted)]">{formatTime(err.occurred_at)}</td>
<td className="px-4 py-2">{err.component}</td>
<td className="px-4 py-2">{err.error_type}</td>
<td className="px-4 py-2"><AutoFixBadge item={err} /></td>
<td className="px-4 py-2 text-xs text-[var(--color-text-muted)] max-w-xs truncate">{err.error_message}</td>
<td className="px-4 py-2 text-center">
{!err.resolved_at ? (
<button
onClick={() => onResolve(err.id)}
disabled={resolving === err.id}
className="text-xs text-[var(--color-primary)] hover:underline disabled:opacity-50"
>
{resolving === err.id ? '...' : '해결됨'}
</button>
) : (
<span className="text-xs text-green-600">{err.resolution_type || '해결'}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Step 3: Create errors page
src/app/pipeline/errors/page.tsx:
'use client';
import { useEffect, useState, useCallback } from 'react';
import StatCard from '@/components/pipeline/stat-card';
import FilterBar from '@/components/pipeline/filter-bar';
import Pagination from '@/components/pipeline/pagination';
import ErrorEscalated, { type EscalatedError } from '@/components/pipeline/error-escalated';
import ErrorTable, { type ErrorItem } from '@/components/pipeline/error-table';
import { ToastContainer, useToast } from '@/components/pipeline/toast';
const STATUS_FILTERS = [
{ label: '미해결', value: 'unresolved' },
{ label: '전체', value: 'all' },
{ label: '해결됨', value: 'resolved' },
];
interface ErrorsResponse {
escalated: EscalatedError[];
items: ErrorItem[];
total: number;
page: number;
limit: number;
stats: {
unresolved: number;
escalated_count: number;
auto_fix_success_rate: number;
};
}
export default function ErrorsPage() {
const [data, setData] = useState<ErrorsResponse | null>(null);
const [statusFilter, setStatusFilter] = useState('unresolved');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [resolving, setResolving] = useState<string | null>(null);
const { toasts, addToast, removeToast } = useToast();
const fetchErrors = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set('resolved', statusFilter);
params.set('page', String(page));
params.set('limit', '20');
const res = await fetch(`/api/pipeline/errors?${params}`);
if (!res.ok) throw new Error('API error');
setData(await res.json());
} catch {
setError('에러 현황을 불러올 수 없습니다.');
} finally {
setLoading(false);
}
}, [statusFilter, page]);
useEffect(() => { fetchErrors(); }, [fetchErrors]);
useEffect(() => { setPage(1); }, [statusFilter]);
const totalPages = data ? Math.ceil(data.total / data.limit) : 0;
async function handleResolve(id: string) {
setResolving(id);
try {
const res = await fetch(`/api/pipeline/errors/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution_type: 'manual_fixed' }),
});
if (!res.ok) throw new Error('Resolve failed');
addToast('success', '에러가 해결됨으로 처리되었습니다.');
fetchErrors();
} catch {
addToast('error', '에러 해결 처리에 실패했습니다.');
} finally {
setResolving(null);
}
}
if (error) {
return (
<div className="text-center py-16">
<p className="text-[var(--color-text-muted)] mb-4">{error}</p>
<button onClick={fetchErrors} className="text-sm text-[var(--color-primary)] hover:underline">재시도</button>
</div>
);
}
return (
<div className="space-y-6">
<ToastContainer toasts={toasts} onRemove={removeToast} />
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">에러 현황</h1>
<button onClick={fetchErrors} className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)]">
새로고침
</button>
</div>
{/* Stats cards */}
<div className="grid grid-cols-3 gap-4">
<StatCard label="미해결 에러" value={data?.stats.unresolved ?? '-'} color="red" />
<StatCard label="에스컬레이션" value={data?.stats.escalated_count ?? '-'} color="red" />
<StatCard label="자동교정 성공률" value={data ? `${data.stats.auto_fix_success_rate}%` : '-'} color="green" />
</div>
{/* Escalated section */}
{!loading && data && (
<ErrorEscalated errors={data.escalated} onResolve={handleResolve} resolving={resolving} />
)}
{/* Filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--color-text-muted)] w-12">상태:</span>
<FilterBar options={STATUS_FILTERS} value={statusFilter} onChange={setStatusFilter} />
</div>
{/* Table */}
{loading ? (
<div className="animate-pulse space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 bg-gray-100 rounded" />
))}
</div>
) : (
<ErrorTable items={data?.items || []} onResolve={handleResolve} resolving={resolving} />
)}
{/* Pagination */}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
);
}
Step 4: Commit
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git add src/components/pipeline/error-escalated.tsx src/components/pipeline/error-table.tsx src/app/pipeline/errors/page.tsx
git commit -m "feat(pipeline): add error dashboard with escalated section and resolve action"
Task 12: E2E 수동 테스트 + 빌드 확인
Depends on: Task 1-11 전부 완료
Step 1: Run build to confirm no compile errors
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && npm run build 2>&1 | tail -20
Expected: Build completed (no type errors, no missing imports)
Step 2: Verify no existing files were modified
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && git diff --name-only src/app/page.tsx src/app/posts/ src/app/api/pipeline/content/route.ts src/app/api/pipeline/approve/route.ts src/app/api/pipeline/reject/route.ts src/styles/global.css
Expected: No output (none of these protected files were changed)
Step 3: Verify all new files exist
Run:
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
ls -la src/app/pipeline/layout.tsx \
src/app/pipeline/page.tsx \
src/app/pipeline/review/page.tsx \
src/app/pipeline/logs/page.tsx \
src/app/pipeline/errors/page.tsx \
src/app/api/pipeline/content/\[id\]/route.ts \
src/app/api/pipeline/stats/route.ts \
src/app/api/pipeline/logs/route.ts \
src/app/api/pipeline/errors/route.ts \
src/app/api/pipeline/errors/\[id\]/resolve/route.ts \
src/components/pipeline/sidebar.tsx \
src/components/pipeline/stat-card.tsx \
src/components/pipeline/status-badge.tsx \
src/components/pipeline/content-list.tsx \
src/components/pipeline/content-preview.tsx \
src/components/pipeline/approve-actions.tsx \
src/components/pipeline/log-table.tsx \
src/components/pipeline/error-table.tsx \
src/components/pipeline/error-escalated.tsx \
src/components/pipeline/filter-bar.tsx \
src/components/pipeline/pagination.tsx \
src/components/pipeline/toast.tsx
Expected: All 22 files listed
Step 4: Test dev server starts
Run: cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline && timeout 15 npm run dev 2>&1 | head -10
Expected: Ready message, no errors
Step 5: Final commit (if any fixes were needed)
cd /Users/nbs22/'(Claude)'/'(claude)'.projects/business-builder/projects/content-pipeline
git pull --rebase origin main && git push origin main
파일 목록 총 정리
신규 API (5개 route)
| # | 파일 | 기능 |
|---|---|---|
| 1 | src/app/api/pipeline/content/[id]/route.ts | 개별 콘텐츠 상세 (content_body 포함) |
| 2 | src/app/api/pipeline/stats/route.ts | 대시보드 요약 통계 |
| 3 | src/app/api/pipeline/logs/route.ts | 실행 로그 목록 (필터, 페이지네이션) |
| 4 | src/app/api/pipeline/errors/route.ts | 에러 목록 (에스컬레이션 분리) |
| 5 | src/app/api/pipeline/errors/[id]/resolve/route.ts | 에러 수동 해결 |
신규 페이지 (5개)
| # | 파일 | 기능 |
|---|---|---|
| 1 | src/app/pipeline/layout.tsx | 파이프라인 전용 레이아웃 (사이드바) |
| 2 | src/app/pipeline/page.tsx | 홈 (요약 카드 + 최근 실행 + 검수 대기) |
| 3 | src/app/pipeline/review/page.tsx | 콘텐츠 승인/거부 (핵심) |
| 4 | src/app/pipeline/logs/page.tsx | 실행 이력 |
| 5 | src/app/pipeline/errors/page.tsx | 에러 현황 |
신규 컴포넌트 (12개)
| # | 파일 | 기능 |
|---|---|---|
| 1 | src/components/pipeline/sidebar.tsx | 사이드바 네비게이션 |
| 2 | src/components/pipeline/stat-card.tsx | 요약 통계 카드 |
| 3 | src/components/pipeline/status-badge.tsx | 상태 배지 |
| 4 | src/components/pipeline/content-list.tsx | 콘텐츠 목록 (승인 화면 좌측) |
| 5 | src/components/pipeline/content-preview.tsx | 콘텐츠 미리보기 (승인 화면 우측) |
| 6 | src/components/pipeline/approve-actions.tsx | 승인/거부 버튼 + 거부 모달 |
| 7 | src/components/pipeline/log-table.tsx | 파이프라인 로그 테이블 |
| 8 | src/components/pipeline/error-table.tsx | 에러 로그 테이블 |
| 9 | src/components/pipeline/error-escalated.tsx | 에스컬레이션 에러 카드 |
| 10 | src/components/pipeline/filter-bar.tsx | 필터 바 (탭) |
| 11 | src/components/pipeline/pagination.tsx | 페이지네이션 |
| 12 | src/components/pipeline/toast.tsx | 토스트 알림 |
변경 없는 파일 (기존 코드 보호)
src/app/page.tsx— 블로그 홈 (변경 없음)src/app/posts/[slug]/page.tsx— 포스트 (변경 없음)src/app/api/pipeline/content/route.ts— 기존 목록 API (변경 없음, 재사용)src/app/api/pipeline/approve/route.ts— 기존 승인 API (변경 없음, 호출)src/app/api/pipeline/reject/route.ts— 기존 거부 API (변경 없음, 호출)src/styles/global.css— 기존 CSS 변수 (변경 없음, 재사용)
리뷰 로그
[uiux-impl-pl 초안 작성] 2026-02-25 22:15
- L1 UI/UX 설계서 분석 완료: 4페이지 + 12컴포넌트 + 5 API 명세
- 기존 코드 리딩 완료: layout.tsx, page.tsx, content/approve/reject API, content-db.ts, pipeline-logger.ts, global.css, package.json, tsconfig.json
- 12개 Task로 분할: 공용 컴포넌트(2) + API(4) + 레이아웃(1) + 페이지(4) + E2E 확인(1)
- 모든 Task에 완전한 코드 포함 (copy-paste 구현 가능)
- 기존 파일 수정 0건 — 모든 코드가 신규 파일
- react-markdown + remark-gfm 이미 package.json에 존재 (추가 설치 불필요)
- @tailwindcss/typography 이미 devDependencies에 존재 (prose 클래스 사용 가능)
- Tailwind CSS v4 @theme + @plugin 문법 확인
- 기존 CSS 변수 재사용: --color-primary, --color-text, --color-text-muted, --color-border, --color-primary-light, --color-tag-bg
- pipeline layout에서 blog header/footer를 CSS로 숨기는 방식 채택 (기존 layout.tsx 수정 불필요)
[자비스 검수] 2026-02-25T21:55:00+09:00
- ✅ writing-plans 스킬 준수: REQUIRED SUB-SKILL 헤더, Goal/Architecture/TechStack, bite-sized Task 12개, 커밋 포함
- ✅ L1 설계서 완전 연계: 4페이지 + 12컴포넌트 + 5 API 전부 구현. 기존 API 4개 재사용 확인
- ✅ 기존 코드 보호 철저: 수정 파일 0건. 모든 구현이 신규 파일. 8개 기존 파일 절대 수정 금지 목록 명시
- ✅ 의존성 명확: Task 1-7 (컴포넌트+API+레이아웃) → Task 8-11 (페이지) → Task 12 (E2E) 순서 보장
- ✅ 완전한 코드 제공: 12 Tasks 전부 copy-paste 구현 가능한 코드 포함
- ✅ 의존성 라이브러리 사전 확인: react-markdown + remark-gfm, @tailwindcss/typography 이미 설치됨 (추가 설치 불필요)
- ✅ Tailwind CSS v4 문법 준수: @theme + @plugin 문법 확인
- ✅ 기존 CSS 변수 재사용: --color-primary/text/border/tag-bg 활용
- ✅ pipeline layout 블로그 분리: 기존 layout.tsx 수정 없이 CSS로 blog header/footer 숨기기
- ✅ Task 12 E2E 테스트: 빌드 확인 + 수동 화면 테스트 체크리스트 포함
- 검수 결론: 승인 요청. VP 승인 시 executing-plans-pl 스폰하여 Task 1→2→...→12 순차 실행 권장