From 6234f4277abf33656e1fb2154b962c22d84393e2 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 15:00:13 +0900 Subject: [PATCH] =?UTF-8?q?docs(phase1):=20=EC=99=B8=EC=A3=BC=20=EC=BD=94?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=ED=98=84=20=ED=94=8C=EB=9E=9C=20(8=20Task?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ --- .../2026-07-02-phase1-outsourcing-core.md | 481 ++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-phase1-outsourcing-core.md diff --git a/docs/superpowers/plans/2026-07-02-phase1-outsourcing-core.md b/docs/superpowers/plans/2026-07-02-phase1-outsourcing-core.md new file mode 100644 index 0000000..3d3bdda --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-phase1-outsourcing-core.md @@ -0,0 +1,481 @@ +# Phase 1 외주 코어 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 발주서 표면화(mypage·admin) + `/showcase` 제작 사례 허브 + admin 광고 관리 + packs 페이지 정리로 외주 코어를 정비한다. + +**Architecture:** 8개 태스크, 서로 파일 비중첩. 신규 데이터 단일 소스는 `lib/showcase-samples.ts`(데모 메타)와 `ad_channels` 테이블(광고 채널). mypage는 기존 의뢰 카드를 유지한 채 상단에 발주·진행 섹션을 추가(정보 손실 없음). + +**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4 (`--jsm-*` 토큰), Supabase, vitest + +**Spec:** `docs/superpowers/specs/2026-07-02-phase1-outsourcing-core-design.md` + +## Global Constraints + +- 디자인 가드레일: gradient / blur / 보라(violet/purple) / 이모지 금지, `--jsm-*` 토큰만 (신규 공개 페이지) +- 카피 가드레일: "대기업 N년차" 류 자격 어필 금지 — "실서비스 직접 운영" 실증 서술만 +- next.config.ts redirects() 수정 금지 +- `/api/admin/packs`·`/api/admin/packs/upload-url`은 삭제 금지 (products·mypage가 공유) +- `app/work/website/samples/**` 데모 8종 수정 금지 (링크만 연결) +- 기존 supabase/migrations/ 파일 삭제·수정 금지, 신규만 추가 +- 커밋은 스코프 파일만 스테이징 — **`git add -A`·`git commit -a` 금지** +- 각 Task 종료 시 `npm test` 전체 통과 + `npm run build` 성공 후 커밋 +- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) ` + +## 확인된 기존 계약 (구현 시 그대로 사용) + +- `GET /api/projects` → `{ projects: [{ id, title, status, total, created_at, milestones: [{ quote_id, step_number, title, status: 'pending'|'in_progress'|'completed', ... }] }] }` — quotes.status ∈ sent/accepted/in_progress/completed/delivered 필터 +- `POST /api/projects/link` — body `{ token: string }` → 200 `{ success: true, quoteId, alreadyLinked? }` / 4xx·5xx `{ error: string }` +- admin API 인증 패턴: `cookies()` → `admin_token` → `verifyAdminTokenNode(token)` 실패 시 401 → `createAdminClient()` (참고: `app/api/admin/services/route.ts`) +- mypage requests 탭: `contact_requests` 기반 카드 리스트(변수명 `orders`), 탭 key `requests` — 라벨만 변경, 기존 카드 유지 + +--- + +### Task 1: showcase 데이터 모듈 (TDD) + +**Files:** +- Create: `lib/showcase-samples.ts` +- Test: `lib/__tests__/showcase-samples.test.ts` + +**Interfaces:** +- Produces: `SHOWCASE_SAMPLES: ShowcaseSample[]`, `type ShowcaseSample = { slug: string; title: string; description: string; tags: string[] }` — Task 2가 import + +- [ ] **Step 1: 실패 테스트 작성** + +`lib/__tests__/showcase-samples.test.ts`: +```typescript +import { describe, it, expect } from 'vitest'; +import { SHOWCASE_SAMPLES } from '../showcase-samples'; + +const EXPECTED_SLUGS = [ + 'bakery', 'corporate', 'dashboard', 'game', + 'interior', 'portfolio', 'reading', 'shopping', +]; + +describe('SHOWCASE_SAMPLES', () => { + it('데모 8종의 slug가 정확히 존재한다', () => { + expect(SHOWCASE_SAMPLES.map((s) => s.slug).sort()).toEqual([...EXPECTED_SLUGS].sort()); + }); + + it('모든 항목에 title/description/tags가 채워져 있다', () => { + for (const s of SHOWCASE_SAMPLES) { + expect(s.title.length).toBeGreaterThan(0); + expect(s.description.length).toBeGreaterThan(0); + expect(s.tags.length).toBeGreaterThan(0); + } + }); + + it('demo 경로는 /work/website/samples/[slug] 형식이다', () => { + for (const s of SHOWCASE_SAMPLES) { + expect(`/work/website/samples/${s.slug}`).toMatch(/^\/work\/website\/samples\/[a-z]+$/); + } + }); +}); +``` + +- [ ] **Step 2: 실패 확인** — Run: `npx vitest run lib/__tests__/showcase-samples.test.ts` / Expected: FAIL (모듈 없음) + +- [ ] **Step 3: 구현** + +`lib/showcase-samples.ts`: +```typescript +/** /showcase 제작 사례 허브의 데모 카드 단일 소스. 데모 실체는 app/work/website/samples/[slug]. */ +export type ShowcaseSample = { + slug: string; + title: string; + description: string; + tags: string[]; +}; + +export const SHOWCASE_SAMPLES: ShowcaseSample[] = [ + { slug: 'corporate', title: '기업 홈페이지', description: 'IT 기업 소개·서비스·문의까지 담은 반응형 공식 홈페이지.', tags: ['기업', '반응형', 'SEO'] }, + { slug: 'shopping', title: '쇼핑몰', description: '상품 목록·상세·장바구니 흐름을 갖춘 커머스 데모.', tags: ['커머스', '결제 흐름'] }, + { slug: 'dashboard', title: 'SaaS 대시보드', description: '지표 카드·차트·테이블로 구성한 관리자 대시보드.', tags: ['대시보드', '차트'] }, + { slug: 'bakery', title: '베이커리 브랜드', description: '메뉴·매장·브랜드 스토리를 담은 로컬 비즈니스 사이트.', tags: ['브랜드', '로컬'] }, + { slug: 'interior', title: '인테리어 포트폴리오', description: '시공 사례 중심의 갤러리형 인테리어 회사 사이트.', tags: ['갤러리', '포트폴리오'] }, + { slug: 'portfolio', title: '디자이너 포트폴리오', description: '작업물·경력·연락처를 담은 개인 포트폴리오.', tags: ['개인', '포트폴리오'] }, + { slug: 'game', title: '게임 프로모션', description: '출시 게임을 소개하는 인터랙티브 프로모션 페이지.', tags: ['프로모션', '랜딩'] }, + { slug: 'reading', title: '도서 콘텐츠', description: '책 소개·리뷰 중심의 콘텐츠 페이지.', tags: ['콘텐츠', '블로그'] }, +]; +``` + +- [ ] **Step 4: 통과 확인** — Run: `npx vitest run lib/__tests__/showcase-samples.test.ts` / Expected: 3 tests PASS +- [ ] **Step 5: 커밋** — `git add lib/showcase-samples.ts lib/__tests__/showcase-samples.test.ts && git commit -m "feat(phase1): showcase 데모 메타 단일 소스 + 무결성 테스트"` + +--- + +### Task 2: /showcase 페이지 + TopNav + robots + +**Files:** +- Create: `app/showcase/page.tsx` +- Modify: `app/components/TopNav.tsx:9-12` (NAV_LINKS에 항목 추가) +- Modify: `app/robots.ts:9` (죽은 경로 3개 제거) + +**Interfaces:** +- Consumes: Task 1의 `SHOWCASE_SAMPLES`, `ShowcaseSample` +- Produces: 공개 라우트 `/showcase` + +- [ ] **Step 1: /showcase 페이지 구현** + +`app/showcase/page.tsx` — 서버 컴포넌트. 구조(스타일은 기존 `app/products/page.tsx`의 카드·섹션 패턴을 Read 후 동일 관용구로): +```tsx +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { SHOWCASE_SAMPLES } from '@/lib/showcase-samples'; + +export const metadata: Metadata = { + title: '제작 사례 | 쟁승메이드', + description: '직접 설계·개발한 웹사이트 데모와 실서비스 운영 사례.', +}; + +// 실운영 서비스(개인 NAS 실서비스 — 외부 링크 없음, 실증 서술만) +const LIVE_SERVICES = [ + { title: '로또 분석 랩', desc: '회차 수집·통계 분석·리포트 자동 생성까지 무인 운영' }, + { title: '주식 자동매매 대시보드', desc: '시세 수집·스크리너·자동 주문을 하나의 콘솔로 운영' }, + { title: 'AI 미디어 파이프라인', desc: '음악·영상·이미지 생성 워커를 큐 기반으로 상시 가동' }, + { title: '여행 사진 갤러리', desc: '수천 장 사진의 지역 분류·썸네일·지도 탐색 자동화' }, +]; + +export default function ShowcasePage() { + return ( +
+ {/* Hero: h1 "제작 사례" + 부제 "실서비스를 직접 만들고 운영하며 검증한 방식 그대로 만듭니다." */} + {/* 섹션 1: 웹사이트 데모 — SHOWCASE_SAMPLES.map 카드 그리드(md:grid-cols-2 lg:grid-cols-4) + 카드: title, description, tags(작은 pill), "데모 보기" 링크 + */} + {/* 섹션 2: 실서비스 운영 — LIVE_SERVICES 카드(링크 없음) + 하단 캡션 + "위 서비스들은 개인 인프라에서 상시 운영 중인 실제 서비스입니다." */} + {/* CTA: "이런 걸 만들어 드립니다" → /outsourcing#contact 버튼(--jsm-accent) */} +
+ ); +} +``` +주석 블록은 실제 JSX로 구현한다(플레이스홀더로 남기지 말 것). 색상은 `var(--jsm-*)` 토큰/기존 Tailwind 클래스 관용구만. + +- [ ] **Step 2: TopNav 링크 추가** + +`app/components/TopNav.tsx`의 NAV_LINKS 배열: +```typescript +const NAV_LINKS = [ + { href: '/outsourcing', label: '외주 개발' }, + { href: '/products', label: '소프트웨어' }, + { href: '/showcase', label: '제작 사례' }, +]; +``` + +- [ ] **Step 3: robots.ts 정리** + +```typescript +disallow: ['/admin/', '/api/', '/mypage/', '/portfolio/'], +``` +(죽은 경로 `/payment/`·`/freelance`·`/services/website` 제거. `/showcase`는 allow '/'에 포함되므로 추가 불필요) + +- [ ] **Step 4: 검증** — `npm test && npm run build` / Expected: PASS + 빌드 라우트에 `/showcase` 등장. 가드레일 grep: `grep -nE "gradient|violet|purple|blur" app/showcase/page.tsx` → 0건 +- [ ] **Step 5: 커밋** — `git add app/showcase/page.tsx app/components/TopNav.tsx app/robots.ts && git commit -m "feat(phase1): /showcase 제작 사례 허브 + TopNav 제작 사례 + robots 죽은 경로 정리"` + +--- + +### Task 3: mypage 발주·진행 섹션 + +**Files:** +- Modify: `app/mypage/page.tsx` (탭 라벨 263행, requests 탭 렌더 518행~) + +**Interfaces:** +- Consumes: `GET /api/projects`, `POST /api/projects/link` (위 "확인된 기존 계약") +- Produces: 없음 (말단 UI) + +- [ ] **Step 1: 상태·타입·페치 추가** + +`app/mypage/page.tsx`에 (기존 state들 옆): +```typescript +type ProjectMilestone = { quote_id: string; step_number: number; title: string; status: 'pending' | 'in_progress' | 'completed' }; +type Project = { id: string; title: string; status: string; total: number; created_at: string; milestones: ProjectMilestone[] }; + +const QUOTE_STATUS_LABELS: Record = { + sent: '견적 발송', accepted: '발주 확정', in_progress: '진행중', completed: '완료', delivered: '납품 완료', +}; + +const [projects, setProjects] = useState([]); +const [linkCode, setLinkCode] = useState(''); +const [linkMsg, setLinkMsg] = useState(null); +const [linking, setLinking] = useState(false); + +const loadProjects = useCallback(async () => { + try { + const res = await fetch('/api/projects'); + if (!res.ok) return; + const d = await res.json(); + setProjects(d.projects ?? []); + } catch { /* 미로그인/네트워크 — 무시 */ } +}, []); +// 기존 초기 로드 useEffect에 loadProjects() 추가 + +const handleLink = async () => { + if (!linkCode.trim() || linking) return; + setLinking(true); setLinkMsg(null); + try { + const res = await fetch('/api/projects/link', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: linkCode.trim() }), + }); + const d = await res.json(); + if (!res.ok) { setLinkMsg(d.error ?? '연결에 실패했습니다.'); return; } + setLinkMsg(d.alreadyLinked ? '이미 연결된 견적서입니다.' : '견적서가 연결되었습니다.'); + setLinkCode(''); + await loadProjects(); + } catch { setLinkMsg('연결에 실패했습니다. 다시 시도해주세요.'); } + finally { setLinking(false); } +}; +``` + +- [ ] **Step 2: 탭 라벨 변경** — 263행: `label: '내 의뢰'` → `label: '발주·진행'` (key `requests` 유지) + +- [ ] **Step 3: requests 탭 상단에 발주·진행 섹션 추가** + +`{tab === 'requests' && (` 블록 최상단(기존 의뢰 카드 리스트는 그대로 아래 유지): +```tsx +{/* 발주·진행 (quotes 기반 — 견적 수락 시 발주서로 전환) */} +
+
+

발주·진행

+ {/* 견적코드 연결: 접이식 — input + 연결 버튼 + linkMsg 표시 */} +
+ {projects.length === 0 ? ( +

진행 중인 발주가 없습니다. 견적서 코드를 입력해 연결하거나 새로 의뢰해 보세요.

+ ) : ( +
+ {projects.map((p) => ( +
+ {/* 헤더: p.title + 상태 뱃지(QUOTE_STATUS_LABELS[p.status] ?? p.status) + + accepted/in_progress/completed/delivered면 "발주서" 뱃지 병기 */} + {/* 총액: p.total.toLocaleString('ko-KR') + '원' */} + {/* 마일스톤 타임라인: p.milestones step_number 순. + completed=accent 채움, in_progress=accent 테두리, pending=회색. + 각 스텝: 번호 원 + title */} +
+ ))} +
+ )} +
+``` +주석 블록은 실제 JSX로 구현. 견적코드 폼: `` + `` + `{linkMsg &&

{linkMsg}

}`. 스타일은 파일 내 기존 폼·뱃지 관용구를 따른다. + +- [ ] **Step 4: 검증** — `npm test && npm run build` PASS. 수동 확인 항목(보고서에 기재): 로그인 후 /mypage?tab=requests에서 발주 섹션·견적코드 폼 렌더 +- [ ] **Step 5: 커밋** — `git add app/mypage/page.tsx && git commit -m "feat(phase1): mypage 발주·진행 섹션 — projects API 배선 + 견적코드 연결"` + +--- + +### Task 4: admin/quotes 발주 뱃지 + 상태 확장 + +**Files:** +- Modify: `app/admin/quotes/page.tsx:12,19` (status 타입·STATUS 맵) + +**Interfaces:** +- Consumes: quotes.status 값 집합 sent/accepted/in_progress/completed/delivered (+draft/rejected) +- Produces: 없음 + +- [ ] **Step 1: 타입·STATUS 맵 확장** + +12행 타입에 `'in_progress' | 'completed' | 'delivered'` 추가. 19행 STATUS 맵(기존 draft/sent/accepted/rejected 스타일 관용구 유지)에: +```typescript +in_progress: { label: '진행중 · 발주', ... }, +completed: { label: '완료 · 발주', ... }, +delivered: { label: '납품 완료 · 발주', ... }, +``` +그리고 기존 `accepted` 라벨을 `'수락 · 발주'`로 변경. 색상 값은 파일 내 기존 STATUS 항목의 색 체계에서 선택(초록 계열=진행/완료, 기존 관용구 확인 후). + +- [ ] **Step 2: 검증** — `npm test && npm run build` PASS +- [ ] **Step 3: 커밋** — `git add app/admin/quotes/page.tsx && git commit -m "feat(phase1): admin 견적 리스트 발주 뱃지 + 진행 상태 라벨 확장"` + +--- + +### Task 5: ad_channels 마이그레이션 + CRUD API + +**Files:** +- Create: `supabase/migrations/2026-07-02-phase1-ad-channels.sql` +- Create: `app/api/admin/ad-channels/route.ts` +- Create: `app/api/admin/ad-channels/[id]/route.ts` + +**Interfaces:** +- Produces: `GET /api/admin/ad-channels` → `{ channels: AdChannel[] }`, `POST` body `{ name, url?, memo? }` → `{ channel }`, `PATCH /api/admin/ad-channels/[id]` body `{ name?, url?, status?, memo? }` → `{ success: true }`, `DELETE` → `{ success: true }`. `AdChannel = { id: string; name: string; url: string | null; status: 'active'|'paused'; memo: string | null; created_at: string; updated_at: string }` — Task 6이 소비 + +- [ ] **Step 1: 마이그레이션 파일** — 스펙 §WS3의 SQL 그대로: +```sql +CREATE TABLE IF NOT EXISTS ad_channels ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + url text, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused')), + memo text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE ad_channels ENABLE ROW LEVEL SECURITY; +-- service_role(관리자 API)만 접근 — 별도 policy 없음(기본 거부) +``` + +- [ ] **Step 2: 목록/생성 API** + +`app/api/admin/ad-channels/route.ts` (인증 패턴은 `app/api/admin/services/route.ts`를 Read 후 동일하게): +```typescript +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { verifyAdminTokenNode } from '@/lib/admin-auth'; + +export const runtime = 'nodejs'; + +async function requireAdmin() { + const cookieStore = await cookies(); + const token = cookieStore.get('admin_token')?.value; + if (!token || !verifyAdminTokenNode(token)) return null; + return createAdminClient(); +} + +export async function GET() { + const supabase = await requireAdmin(); + if (!supabase) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { data, error } = await supabase.from('ad_channels').select('*').order('created_at', { ascending: false }); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ channels: data ?? [] }); +} + +export async function POST(request: Request) { + const supabase = await requireAdmin(); + if (!supabase) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const body = await request.json(); + const name = (body.name as string | undefined)?.trim(); + if (!name) return NextResponse.json({ error: '채널명을 입력해주세요.' }, { status: 400 }); + const { data, error } = await supabase + .from('ad_channels') + .insert({ name, url: body.url?.trim() || null, memo: body.memo?.trim() || null }) + .select().single(); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ channel: data }); +} +``` + +- [ ] **Step 3: 수정/삭제 API** + +`app/api/admin/ad-channels/[id]/route.ts`: +```typescript +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { verifyAdminTokenNode } from '@/lib/admin-auth'; + +export const runtime = 'nodejs'; + +async function requireAdmin() { + const cookieStore = await cookies(); + const token = cookieStore.get('admin_token')?.value; + if (!token || !verifyAdminTokenNode(token)) return null; + return createAdminClient(); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const supabase = await requireAdmin(); + if (!supabase) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { id } = await params; + const body = await request.json(); + const patch: Record = { updated_at: new Date().toISOString() }; + if (typeof body.name === 'string' && body.name.trim()) patch.name = body.name.trim(); + if ('url' in body) patch.url = body.url?.trim() || null; + if ('memo' in body) patch.memo = body.memo?.trim() || null; + if (body.status === 'active' || body.status === 'paused') patch.status = body.status; + const { error } = await supabase.from('ad_channels').update(patch).eq('id', id); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ success: true }); +} + +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const supabase = await requireAdmin(); + if (!supabase) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { id } = await params; + const { error } = await supabase.from('ad_channels').delete().eq('id', id); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ success: true }); +} +``` +(주의: Next 16에서 route context의 `params`는 Promise — 기존 `app/api/admin/quotes/[id]/route.ts`의 시그니처를 Read 후 동일 관용구로 맞출 것) + +- [ ] **Step 4: 검증** — `npm test && npm run build` PASS (라우트 2개 빌드 등장) +- [ ] **Step 5: 커밋** — `git add supabase/migrations/2026-07-02-phase1-ad-channels.sql app/api/admin/ad-channels && git commit -m "feat(phase1): ad_channels 테이블 + admin CRUD API"` + +--- + +### Task 6: admin 광고 관리 페이지 재편 + 사이드바 + +**Files:** +- Modify: `app/admin/marketing/page.tsx` (2탭 재구성 — 기존 에셋 UI는 탭 안으로 이동) +- Modify: `app/admin/components/AdminSidebar.tsx:90-91` (`마케팅 에셋` → `광고 관리`) + +**Interfaces:** +- Consumes: Task 5의 ad-channels API 계약 (`AdChannel` 타입 포함) +- Produces: 없음 + +- [ ] **Step 1: 페이지 2탭 재구성** + +`app/admin/marketing/page.tsx`: 파일 상단에 `type AdminTab = 'channels' | 'assets'` state 추가, 페이지 타이틀 `광고 관리`. 기존 에셋 렌더 전체(통계 카드·그리드·모달)를 `{tab === 'assets' && (...)}`로 감싸고, `channels` 탭(기본값)에 채널 CRUD UI 구현: +```tsx +// channels 탭 구성: +// - 상단: 신규 채널 추가 폼 (name 필수, url, memo) → POST /api/admin/ad-channels +// - 테이블: 채널명 | URL(외부 링크, 없으면 '-') | 상태 토글(active↔paused, PATCH) | 메모(인라인 편집 또는 표시) | 등록일 | 삭제 버튼(confirm 후 DELETE) +// - fetch는 useEffect 초기 1회 GET + 각 뮤테이션 후 재조회 +// - 에러는 상단 배너 텍스트로 표시 +``` +주석은 실제 구현으로. 스타일은 기존 admin 페이지들(orders/products)의 테이블·버튼 관용구를 Read 후 동일하게. + +- [ ] **Step 2: 사이드바 라벨** — `label: '마케팅 에셋'` → `label: '광고 관리'` (href 유지) +- [ ] **Step 3: 검증** — `npm test && npm run build` PASS +- [ ] **Step 4: 커밋** — `git add app/admin/marketing/page.tsx app/admin/components/AdminSidebar.tsx && git commit -m "feat(phase1): admin 광고 관리 — 채널·캠페인 CRUD 탭 + 에셋 탭 재편"` + +--- + +### Task 7: admin/packs 페이지 제거 + +**Files:** +- Delete: `app/admin/packs/page.tsx` (디렉토리째) +- Modify: `app/admin/components/AdminSidebar.tsx:80-81` (`팩 자료` 메뉴 항목 객체 제거) + +**Interfaces:** +- Consumes: 없음 +- Produces: 없음. `/api/admin/packs*`는 유지(products·mypage 공유 — Global Constraints) + +- [ ] **Step 1: 삭제** — `git rm -r app/admin/packs` + AdminSidebar에서 href `/admin/packs` 객체(svg 포함) 제거 +- [ ] **Step 2: 잔존 참조 확인** — `grep -rn "admin/packs" app lib` → 허용되는 매치: `app/admin/products/page.tsx`·`app/mypage`의 `/api/admin/packs`(API 호출)뿐. 페이지 라우트 `/admin/packs` href 참조 0건 +- [ ] **Step 3: 검증** — `npm test && npm run build` PASS +- [ ] **Step 4: 커밋** — `git add -u app/admin && git commit -m "chore(phase1): admin/packs 레거시 페이지 제거 (API는 products·mypage 공유로 유지)"` + +--- + +### Task 8: CLAUDE.md 갱신 + 이메일 경로 점검 + 최종 검증 + +**Files:** +- Modify: `CLAUDE.md` (IA 표·admin 서술) + +**Interfaces:** +- Consumes: Task 1~7 완료 상태 +- Produces: 문서 정합 + 검증 보고 + +- [ ] **Step 1: CLAUDE.md 갱신** +- 핵심 IA 표에 `| /showcase | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |` 추가 +- `/mypage` 행: `4탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역`으로 갱신 +- admin 서술: `packs` 제거, `marketing`→`광고 관리(채널 CRUD + 에셋)` 반영 +- 파일 구조 트리에 `showcase/page.tsx`, `api/admin/ad-channels/` 추가 + +- [ ] **Step 2: 이메일 경로 점검 (변경 없음, 검증만)** +`lib/request-emails.ts`·`lib/order-emails.ts`의 export 함수들이 각각 `app/api/contact/route.ts`·`app/api/admin/quotes/[id]/send/route.ts`·`app/api/quote/[token]/route.ts`·`app/api/orders/route.ts`·`app/api/admin/orders/route.ts`에서 여전히 import·호출되는지 grep으로 확인하고 결과를 보고서에 기재 (Phase 0 삭제가 메일 경로를 건드리지 않았음을 실증) + +- [ ] **Step 3: 최종 검증** +```bash +npm test # showcase-samples 테스트 포함 전체 PASS +npm run build # /showcase 라우트 존재, /admin/packs 라우트 소멸 +grep -nE "gradient|violet|purple|blur" app/showcase/page.tsx # 0건 +``` + +- [ ] **Step 4: 커밋** — `git add CLAUDE.md && git commit -m "docs(phase1): CLAUDE.md — showcase·발주 탭·광고 관리·packs 정리 반영"` + +- [ ] **Step 5: CEO 안내 (보고)** +- `2026-07-02-phase1-ad-channels.sql`을 클라우드 Supabase + NAS self-host 양쪽 적용 +- 수동 확인 2종: /mypage 발주·진행 탭(견적코드 연결), /admin/marketing 채널 CRUD