From 5751cddceacaaf0c5e603880938f00dc718bcb55 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 12 Jun 2026 01:16:46 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=EB=A6=AC=EB=89=B4=EC=96=BC=20Pha?= =?UTF-8?q?se=203=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20=E2=80=94=20?= =?UTF-8?q?=EC=99=B8=EC=A3=BC=20=EA=B3=A0=EA=B0=9D=20=ED=8F=AC=ED=84=B8=20?= =?UTF-8?q?+=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-12-renewal-phase3-client-portal.md | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-renewal-phase3-client-portal.md diff --git a/docs/superpowers/plans/2026-06-12-renewal-phase3-client-portal.md b/docs/superpowers/plans/2026-06-12-renewal-phase3-client-portal.md new file mode 100644 index 0000000..31c2451 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-renewal-phase3-client-portal.md @@ -0,0 +1,485 @@ +# 사이트 리뉴얼 Phase 3 — 외주 고객 포털 구현 계획 + +> **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. +> **UI 태스크(4·5·8·9)는 구현 시 `designer` + `soft-skill` 스킬 로드 필수.** 노출 페이지 토큰은 `--jsm-*`만, gradient/blur/보라/이모지 금지. admin은 기존 admin 톤 유지. + +**Goal:** 외주 의뢰를 접수→검토→견적→수락→진행→완료의 상태 머신으로 관리하고, 고객이 `/track/[token]`(비회원)·mypage(회원)에서 추적·견적 수락/거절할 수 있는 포털 구축. 잔여 정리(레거시 음악 구매 경로 차단, CLAUDE.md 갱신) 포함. + +**Architecture:** `contact_requests`에 상태 머신·`public_token`·구조화 필드(project_type/budget/timeline)를 확장하고 `quotes.contact_request_id` FK로 연결 (스펙 §1-1 A안). 견적 발송/수락/거절 시 양 테이블 상태를 서버에서 동기화. 메일은 기존 Resend 패턴(`lib/order-emails.ts`) 준용한 `lib/request-emails.ts`. + +**Tech Stack:** Next.js 16 App Router, Supabase, Resend, vitest + +**Spec:** `docs/superpowers/specs/2026-06-11-site-renewal-outsourcing-products-design.md` §5·§6 +**Branch:** `feature/renewal-phase3` + +--- + +## 현재 코드 기준점 (탐색 검증됨) + +- `contact_requests`: id/user_id/email/name/phone/service/message/status(default 'pending', **CHECK 없음**)/created_at. RLS: 본인 SELECT + 누구나 INSERT +- `quotes`: title/client_name/client_email/client_phone/status(draft|sent|accepted|rejected)/public_token/valid_until/wbs/items/maintenance/notes/discount/accepted_* — **contact_request_id 없음**, 견적 발송 메일 기능 없음 +- `/api/contact` (`app/api/contact/route.ts`): sanitizeStr+INPUT_LIMITS 검증(34-38행), IP rate limit 5/분(19-29행), 관리자 메일(76-96행), insert(104-112행, user_id 포함) +- `ContactForm.tsx`: 단일 폼, `?service=` 프리필. `/outsourcing` `#contact`에서 props 없이 사용 +- `/quote/[token]`: 열람+**수락만** 있음(거절 없음). 수락 POST가 quotes만 갱신, **contact_requests 동기화 없음** +- `admin/contacts`: 3종 status(pending/in_progress/completed) 토글. `admin/quotes`: CRUD + `[id]` 편집 페이지 존재(주장 충돌 있음 — 구현 시 직접 확인), public_token 생성 로직은 명시적으로 없음(DB default 추정 — **구현 시 확인 필수**) +- `mypage` 내 의뢰 탭: contact_requests 카드 목록(StatusBadge 3종) +- 메일 패턴: `lib/order-emails.ts` (Resend, FROM '쟁승메이드 ', ADMIN bgg8988@gmail.com, escapeHtml) +- 고아 경로: `/music/packs`(숨김)가 PurchaseAgreementModal로 contact 문자열 구매 신청 생성 — orders에 안 잡힘 +- 사이트 URL 상수: `https://jaengseung-made.com` + +## 상태 머신 (단일 정의 — Task 2의 lib가 유일한 소스) + +``` +pending(접수) → reviewing(검토중) → quoted(견적 발송) → accepted(수주 확정) → in_progress(진행중) → completed(완료) + ↘ on_hold(보류) (어느 단계서든) cancelled(취소) +``` +레거시 값(pending/in_progress/completed)은 그대로 유효 — 기존 행 변환 불필요. + +--- + +### Task 1: DB 마이그레이션 — contact_requests 확장 + quotes FK + +**Files:** +- Create: `supabase/migrations/2026-06-12-client-portal.sql` + +- [ ] **Step 1: SQL 작성** (멱등) + +```sql +-- 2026-06-12 Phase 3: 외주 고객 포털 +-- (1) contact_requests 확장 +ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS public_token text UNIQUE; +ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS project_type text; +ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS budget text; +ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS timeline text; +ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now(); + +-- 기존 행 토큰 백필 (멱등 — NULL만) +UPDATE contact_requests SET public_token = gen_random_uuid()::text WHERE public_token IS NULL; + +-- 상태 머신 CHECK (레거시 3종 포함 8종) +ALTER TABLE contact_requests DROP CONSTRAINT IF EXISTS contact_requests_status_check; +ALTER TABLE contact_requests ADD CONSTRAINT contact_requests_status_check + CHECK (status IN ('pending','reviewing','quoted','accepted','on_hold','in_progress','completed','cancelled')); + +-- (2) quotes ↔ contact_requests 연결 +ALTER TABLE quotes ADD COLUMN IF NOT EXISTS contact_request_id uuid REFERENCES contact_requests(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_quotes_contact_request ON quotes (contact_request_id); + +-- (3) quotes.public_token 기본값 보장 (기존 default 없을 때만 의미, 멱등) +ALTER TABLE quotes ALTER COLUMN public_token SET DEFAULT gen_random_uuid()::text; +UPDATE quotes SET public_token = gen_random_uuid()::text WHERE public_token IS NULL; +``` + +- [ ] **Step 2: schema.sql 대조** — contact_requests/quotes 컬럼명 정합 확인. quotes에 public_token 컬럼이 실제 존재하는지 확인(없으면 ADD COLUMN IF NOT EXISTS 추가) +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/2026-06-12-client-portal.sql +git commit -m "feat(db): 고객 포털 — contact_requests 상태머신·토큰 + quotes FK" +``` + +(운영 적용은 배포 전 — 클라우드+NAS 양쪽. Phase 2와 동일 절차) + +--- + +### Task 2: `lib/request-status.ts` (상태 머신, TDD) + `lib/request-emails.ts` + +**Files:** +- Create: `lib/request-status.ts` +- Test: `lib/__tests__/request-status.test.ts` +- Create: `lib/request-emails.ts` + +- [ ] **Step 1: 실패하는 테스트** — `lib/__tests__/request-status.test.ts` + +```typescript +import { describe, it, expect } from 'vitest'; +import { REQUEST_STATUS, TIMELINE_STEPS, timelineIndex, isRequestStatus } from '@/lib/request-status'; + +describe('request-status', () => { + it('8개 상태 라벨 정의', () => { + expect(Object.keys(REQUEST_STATUS)).toHaveLength(8); + expect(REQUEST_STATUS.quoted.label).toBe('견적 발송'); + }); + it('타임라인은 정주행 6단계', () => { + expect(TIMELINE_STEPS).toEqual(['pending','reviewing','quoted','accepted','in_progress','completed']); + }); + it('timelineIndex — 정주행 상태는 해당 인덱스', () => { + expect(timelineIndex('pending')).toBe(0); + expect(timelineIndex('completed')).toBe(5); + }); + it('timelineIndex — on_hold는 quoted 위치(2), cancelled는 -1', () => { + expect(timelineIndex('on_hold')).toBe(2); + expect(timelineIndex('cancelled')).toBe(-1); + }); + it('isRequestStatus 가드', () => { + expect(isRequestStatus('quoted')).toBe(true); + expect(isRequestStatus('nope')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: 실패 확인** — `npm test` → FAIL +- [ ] **Step 3: 구현** — `lib/request-status.ts` + +```typescript +/** 외주 의뢰 상태 머신 — DB CHECK(2026-06-12-client-portal.sql)와 단일 동기 소스 */ +export type RequestStatus = + | 'pending' | 'reviewing' | 'quoted' | 'accepted' + | 'on_hold' | 'in_progress' | 'completed' | 'cancelled'; + +export const REQUEST_STATUS: Record = { + pending: { label: '접수' }, + reviewing: { label: '검토중' }, + quoted: { label: '견적 발송' }, + accepted: { label: '수주 확정' }, + on_hold: { label: '보류' }, + in_progress: { label: '진행중' }, + completed: { label: '완료' }, + cancelled: { label: '취소' }, +}; + +/** 고객 타임라인 정주행 단계 (on_hold/cancelled는 별도 표기) */ +export const TIMELINE_STEPS: RequestStatus[] = [ + 'pending', 'reviewing', 'quoted', 'accepted', 'in_progress', 'completed', +]; + +/** 타임라인에서 현재 위치. on_hold→quoted 위치, cancelled→-1 */ +export function timelineIndex(status: RequestStatus): number { + if (status === 'cancelled') return -1; + if (status === 'on_hold') return TIMELINE_STEPS.indexOf('quoted'); + return TIMELINE_STEPS.indexOf(status); +} + +export function isRequestStatus(v: unknown): v is RequestStatus { + return typeof v === 'string' && v in REQUEST_STATUS; +} +``` + +- [ ] **Step 4: 통과 확인** — `npm test` → 기존 5 + 신규 5 = 10 passed +- [ ] **Step 5: `lib/request-emails.ts`** (escapeHtml은 `@/lib/security`에서): + +```typescript +import { Resend } from 'resend'; +import { escapeHtml } from '@/lib/security'; + +const FROM = '쟁승메이드 '; +const ADMIN_EMAIL = 'bgg8988@gmail.com'; +const SITE = 'https://jaengseung-made.com'; + +function resend() { + return new Resend(process.env.RESEND_API_KEY); +} + +/** 의뢰 접수 확인 — 고객에게 추적 링크 발송 */ +export async function sendRequestReceivedEmail(opts: { + name: string; email: string; service: string; publicToken: string; +}) { + const { name, email, service, publicToken } = opts; + await resend().emails.send({ + from: FROM, + to: [email], + subject: '[쟁승메이드] 의뢰가 접수되었습니다', + html: ` +

의뢰가 접수되었습니다

+

${escapeHtml(name)}님, ${escapeHtml(service)} 의뢰가 정상 접수되었습니다.

+

영업일 2일 내에 회신드리며, 아래 링크에서 진행 상태를 언제든 확인하실 수 있습니다.

+

의뢰 진행 상태 확인하기

+
+

이 링크는 본인 확인용입니다. 타인과 공유하지 마세요.

+ `, + }); +} + +/** 견적 발송 — 고객에게 견적 링크 */ +export async function sendQuoteSentEmail(opts: { + clientName: string; clientEmail: string; quoteTitle: string; quoteToken: string; validUntil: string | null; +}) { + const { clientName, clientEmail, quoteTitle, quoteToken, validUntil } = opts; + await resend().emails.send({ + from: FROM, + to: [clientEmail], + subject: `[쟁승메이드] 견적서가 도착했습니다 — ${escapeHtml(quoteTitle)}`, + html: ` +

견적서를 보내드립니다

+

${escapeHtml(clientName)}님, 요청하신 건의 견적서가 준비되었습니다.

+

견적서 확인하기

+ ${validUntil ? `

유효기간: ${escapeHtml(validUntil.slice(0, 10))}

` : ''} +

견적서 페이지에서 바로 수락하시거나, 회신으로 문의 주세요.

+ `, + }); +} + +/** 견적 수락/거절 — 관리자 알림 */ +export async function sendQuoteDecisionEmail(opts: { + decision: 'accepted' | 'rejected'; quoteTitle: string; clientName: string; total?: number; +}) { + const { decision, quoteTitle, clientName, total } = opts; + const label = decision === 'accepted' ? '수락' : '거절'; + await resend().emails.send({ + from: FROM, + to: [ADMIN_EMAIL], + subject: `[쟁승메이드] 견적 ${label} — ${escapeHtml(quoteTitle)}`, + html: ` +

고객이 견적을 ${label}했습니다

+

견적: ${escapeHtml(quoteTitle)} / 고객: ${escapeHtml(clientName)}

+ ${typeof total === 'number' ? `

수락 금액: ₩${total.toLocaleString('ko-KR')}

` : ''} +

견적 관리로 이동

+ `, + }); +} +``` + +- [ ] **Step 6: 빌드 확인 + Commit** + +```bash +npm test && npm run build +git add lib/request-status.ts lib/__tests__/request-status.test.ts lib/request-emails.ts +git commit -m "feat(portal): 의뢰 상태 머신(TDD) + 의뢰/견적 메일" +``` + +--- + +### Task 3: `/api/contact` 확장 — 구조화 필드 + 토큰 + 고객 접수 메일 + +**Files:** +- Modify: `app/api/contact/route.ts` + +- [ ] **Step 1:** 기존 검증·rate limit·관리자 메일 **무수정** 유지하고: + 1. body에서 `projectType`/`budget`/`timeline`도 sanitizeStr(각 100자)로 수신 (없으면 null — 기존 호출자 호환) + 2. `const publicToken = crypto.randomUUID();` (Node crypto — `import crypto from 'crypto'` 또는 Web Crypto `globalThis.crypto.randomUUID()`) + 3. insert에 `public_token: publicToken, project_type, budget, timeline` 추가 + 4. **insert 성공 후** 고객 접수 확인 메일: `sendRequestReceivedEmail({ name, email, service: service || '외주 문의', publicToken })` — try/catch로 실패 격리(console.error) + 5. 성공 응답에 `trackUrl: '/track/' + publicToken` 포함 (폼 완료 화면에서 안내용) +- [ ] **Step 2:** `npm run build` + dev에서 curl로 기존 검증(빈 body 400) 회귀 확인 +- [ ] **Step 3: Commit** + +```bash +git add app/api/contact/route.ts +git commit -m "feat(contact): 구조화 필드 + 추적 토큰 + 고객 접수 확인 메일" +``` + +--- + +### Task 4: 단계형 의뢰 폼 — `OutsourcingRequestForm` + +> **designer + soft-skill 로드 필수.** + +**Files:** +- Create: `app/components/OutsourcingRequestForm.tsx` +- Modify: `app/outsourcing/page.tsx` (#contact 섹션의 ContactForm → 신규 폼 교체) +- 보존: `app/components/ContactForm.tsx` (레거시 페이지 사용 가능성 — 무수정) + +- [ ] **Step 1: 4단계 폼 구현** ('use client', 진행 표시기 포함): + +| 단계 | 필드 | 비고 | +|------|------|------| +| ① 프로젝트 유형 | 카드 선택 1개: 웹 서비스 / 웹사이트 / 업무 자동화 / API·백엔드 / 봇 개발 / AI 연동 / 기타 | `projectType` | +| ② 예산·일정 | 예산 선택(100만원 미만 / 100~300 / 300~1,000 / 1,000만원 이상 / 미정) + 희망 일정 선택(1개월 내 / 1~3개월 / 3개월+ / 미정) | `budget`, `timeline` | +| ③ 상세 내용 | textarea (필수, "참고 서비스·기능·현재 상황을 자유롭게") | `message` | +| ④ 연락처 | 이름(필수)·이메일(필수)·연락처(선택) — **로그인 상태면 createClient().auth.getUser()로 이메일 자동 채움** | | + +- 단계 이동: [다음]/[이전], 각 단계 유효성 검사 후 진행. 제출 시 `POST /api/contact`에 `{ name, phone, email, service: '외주 개발 문의 — ' + projectType, message, projectType, budget, timeline }` +- 완료 화면: "의뢰가 접수되었습니다. 영업일 2일 내 회신드립니다." + 응답의 `trackUrl`로 [진행 상태 확인하기] 버튼 + "메일로도 추적 링크를 보내드렸습니다" +- 디자인: `--jsm-*` 토큰, outsourcing 페이지 톤과 일관. 단계 표시기는 숫자+라벨의 절제된 형태 + +- [ ] **Step 2:** `app/outsourcing/page.tsx` #contact 섹션에서 `` → `` 교체 (섹션 제목·안내 문구 유지) +- [ ] **Step 3:** build + dev: /outsourcing 200 + 폼 마크업 존재. 가능하면 실제 제출 1회(메일 2통: 관리자+고객 확인) +- [ ] **Step 4: Commit** + +```bash +git add app/components/OutsourcingRequestForm.tsx app/outsourcing/page.tsx +git commit -m "feat(outsourcing): 4단계 의뢰 폼 + 접수 완료 추적 안내" +``` + +--- + +### Task 5: `/track/[token]` 비회원 추적 페이지 + +> **designer + soft-skill 로드 필수.** + +**Files:** +- Create: `app/api/track/[token]/route.ts` +- Create: `app/track/[token]/page.tsx` + +- [ ] **Step 1: API** — GET, 서버 admin client로 토큰 조회 (RLS 우회 — 토큰 자체가 비밀): + +```typescript +import { NextResponse } from 'next/server'; +import { createAdminClient } from '@/lib/supabase/admin'; + +export const runtime = 'nodejs'; + +export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }) { + const { token } = await params; + if (!token || token.length > 64) return NextResponse.json({ error: 'not found' }, { status: 404 }); + + const admin = createAdminClient(); + const { data: request } = await admin + .from('contact_requests') + .select('id, name, service, status, project_type, budget, timeline, created_at, updated_at') + .eq('public_token', token) + .maybeSingle(); + if (!request) return NextResponse.json({ error: 'not found' }, { status: 404 }); + + // 연결된 견적 (sent 이상만 노출 — draft는 비공개) + const { data: quote } = await admin + .from('quotes') + .select('public_token, title, status, valid_until') + .eq('contact_request_id', request.id) + .in('status', ['sent', 'accepted', 'rejected']) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + + return NextResponse.json({ request, quote: quote ?? null }); +} +``` + +(이메일 등 PII는 응답에서 제외 — name·service·상태만) + +- [ ] **Step 2: 페이지** — 서버 컴포넌트(자체 fetch 대신 위 로직을 직접 호출해도 됨 — admin client 직접 사용 가능). 구성: + - 헤더: "의뢰 진행 상태" + 의뢰명(service)·접수일 + - **타임라인**: `TIMELINE_STEPS` × `timelineIndex(status)` — 완료 단계는 accent 채움, 현재 단계 강조, 미래 단계는 회색. `on_hold`면 "보류 중" 배너, `cancelled`면 "취소됨" 배너 + - **견적 카드** (quote 있을 때): 제목·상태·유효기간 + [견적서 보기 → /quote/[quote.public_token]] + - 하단: "문의: bgg8988@gmail.com" + 회원이면 mypage 안내 + - 토큰 불일치 → `notFound()` + - metadata: `robots: { index: false }` (비공개 페이지) +- [ ] **Step 3:** build + dev: 존재하지 않는 토큰 404, (마이그레이션 적용 전 로컬은 컬럼 없음 에러 가능 → try/catch notFound 폴백 확인) +- [ ] **Step 4: Commit** + +```bash +git add app/api/track/ app/track/ +git commit -m "feat(portal): /track/[token] 비회원 의뢰 추적 페이지" +``` + +--- + +### Task 6: 견적 연계 — contact 연결·발송 메일·상태 동기화 (admin) + +**Files:** +- Modify: `app/api/admin/quotes/route.ts` (POST에 contact_request_id·프리필) +- Create: `app/api/admin/quotes/[id]/send/route.ts` (견적 발송) +- Modify: `app/admin/contacts/page.tsx` (상세 패널에 [견적서 작성] 버튼) + +- [ ] **Step 1: quotes POST 확장** — body에 `contact_request_id?`, `client_name?`, `client_email?` 허용. `contact_request_id`가 오면 insert에 포함. (기존 무인자 호출 호환 유지) +- [ ] **Step 2: 발송 API** — `app/api/admin/quotes/[id]/send/route.ts` POST (checkAuth 패턴): + 1. quotes에서 id로 조회 — client_email 없으면 400 "고객 이메일을 먼저 입력하세요" + 2. `public_token` 없으면 `crypto.randomUUID()` 생성·저장 + 3. `quotes.status = 'sent'` + updated_at + 4. `contact_request_id` 있으면 `contact_requests.status = 'quoted'` + updated_at 동기화 + 5. `sendQuoteSentEmail({ clientName: quote.client_name, clientEmail, quoteTitle: quote.title, quoteToken, validUntil })` — try/catch, 실패 시 응답에 `emailSent: false` 표시 (상태 변경은 유지) + 6. `{ success: true, emailSent }` +- [ ] **Step 3: admin/contacts 상세 패널에 [견적서 작성] 버튼** — 클릭 시 `POST /api/admin/quotes`에 `{ title: contact.service + ' — ' + (contact.name ?? ''), contact_request_id: contact.id, client_name: contact.name, client_email: contact.email }` → 응답 quote.id로 `/admin/quotes/[id]` 이동(기존 편집 페이지). 이미 연결된 견적이 있으면(추가 GET 필요 없이 단순 생성 허용 — 다건 연결 가능, 최신 sent만 고객 노출) +- [ ] **Step 4:** admin/quotes 편집 페이지(`app/admin/quotes/[id]/page.tsx`)에 **[고객에게 발송]** 버튼 추가 — `POST /api/admin/quotes/[id]/send` 호출, 성공 시 "발송됨 + 메일 전송 여부" 토스트/배너. (편집 페이지가 실제로 존재하는지 먼저 확인 — 없으면 BLOCKED 보고 말고 quotes 목록 페이지에 발송 버튼 추가로 대체하고 보고에 명시) +- [ ] **Step 5:** build + 비인증 curl 401 확인 + Commit + +```bash +git add app/api/admin/quotes/ app/admin/contacts/page.tsx app/admin/quotes/ +git commit -m "feat(admin): 의뢰→견적 연결 생성 + 견적 발송(메일·상태 동기화)" +``` + +--- + +### Task 7: 견적 수락/거절 — 고객 측 + 동기화 + +**Files:** +- Modify: `app/api/quote/[token]/route.ts` (수락 POST에 동기화 + 거절 처리) +- Modify: `app/quote/[token]/page.tsx` (거절 버튼) + +- [ ] **Step 1: API 확장** — 기존 POST(수락)에: + 1. body에 `action: 'accept' | 'reject'` 추가 (기존 호출 호환: action 없으면 'accept') + 2. reject면 `quotes.status='rejected'` + updated_at만 (accepted_* 미기록) + 3. 공통: 해당 quote의 `contact_request_id`가 있으면 `contact_requests.status` 동기화 — accept→`'accepted'`, reject→`'on_hold'` (+updated_at) + 4. `sendQuoteDecisionEmail({ decision, quoteTitle: quote.title, clientName: quote.client_name, total: acceptedTotal })` — try/catch 격리 + 5. 이미 accepted/rejected 상태면 409 "이미 처리된 견적입니다" +- [ ] **Step 2: 페이지** — 하단 고정 바에 [정중히 거절] 보조 버튼(고스트) 추가 — confirm("견적을 거절하시겠습니까? 다른 조건이 필요하시면 회신 주세요.") → POST {action:'reject'} → "의견 감사합니다. 조정이 필요하시면 언제든 회신 주세요" 화면. 수락 버튼·계산 로직 무수정 +- [ ] **Step 3:** build + dev: 존재하지 않는 토큰 404 회귀 + Commit + +```bash +git add "app/api/quote/[token]/route.ts" "app/quote/[token]/page.tsx" +git commit -m "feat(quote): 거절 액션 + 의뢰 상태 동기화 + 관리자 알림" +``` + +--- + +### Task 8: admin/contacts 상태 머신 고도화 + +**Files:** +- Modify: `app/admin/contacts/page.tsx` +- Modify: `app/api/admin/contacts/route.ts` (PATCH status 검증) + +- [ ] **Step 1: API PATCH** — `isRequestStatus(status)` 검증 추가(불통과 400), update에 `updated_at` 포함 +- [ ] **Step 2: 페이지** — + - STATUS 매핑을 `REQUEST_STATUS`(lib) 기반 8종으로 교체 (필터 탭: 전체/접수/검토중/견적 발송/수주 확정/진행중/완료 — on_hold·cancelled는 '기타' 묶음 또는 개별, 카운트 표시) + - 상세 패널: 상태 변경을 8종 드롭다운(또는 버튼 그룹)으로, project_type/budget/timeline 표시(있을 때), **/track 링크 복사 버튼**(public_token 있을 때), 연결 견적 존재 시 [견적 보기 → /admin/quotes/[id]] (GET 응답에 견적 join이 없으므로 — `/api/admin/contacts` GET에서 quotes(contact_request_id 매칭 id,title,status)를 2쿼리 머지로 포함) +- [ ] **Step 3:** build + 비인증 401 회귀 + Commit + +```bash +git add app/admin/contacts/page.tsx app/api/admin/contacts/route.ts +git commit -m "feat(admin): 의뢰 관리 8종 상태 머신 + 견적 연결·추적 링크 표시" +``` + +--- + +### Task 9: mypage '내 의뢰' 타임라인 + +> **designer + soft-skill 로드 필수.** + +**Files:** +- Modify: `app/mypage/page.tsx` (내 의뢰 탭만) + +- [ ] **Step 1:** 의뢰 카드 확장 — + - StatusBadge를 `REQUEST_STATUS` 8종 라벨로 교체 (기존 3종 매핑 대체, 색상: completed 그린/in_progress·accepted accent/quoted accent-soft/cancelled·on_hold 회색 계열) + - 카드 클릭(또는 펼침) 시 **미니 타임라인** (`TIMELINE_STEPS`+`timelineIndex` — track 페이지와 동일 로직, 컴팩트 렌더) + - `public_token` 있으면 [상세 추적 → /track/[token]] 링크 + - 연결 견적은 track API처럼 client에서 quotes를 직접 조회하지 않고(RLS) — 간단히 /track 링크로 유도 (YAGNI) +- [ ] **Step 2:** contact_requests select에 신규 컬럼(public_token, project_type, budget, timeline, updated_at) 포함되는지 확인(`select('*')`라 자동). 다른 탭 무수정 +- [ ] **Step 3:** build + /mypage 200 + Commit + +```bash +git add app/mypage/page.tsx +git commit -m "feat(mypage): 내 의뢰 타임라인 + 추적 링크" +``` + +--- + +### Task 10: 레거시 정리 — music 구매 고아 경로 차단 + CLAUDE.md 갱신 + +**Files:** +- Modify: `next.config.ts` +- Modify: `CLAUDE.md` (jaengseung-made) + +- [ ] **Step 1: redirect 추가** — `/music/packs` → `/products` (permanent). `/music/samples`·`/music/studio`는 숨김 가드 유지(admin 열람용). 기존 `/services/music` redirect의 destination도 `/products`로 갱신(체인 방지) +- [ ] **Step 2: CLAUDE.md 갱신** — 다음 섹션을 현행화: + - 핵심 서비스 표: `/outsourcing`(외주)·`/products`(완성 소프트웨어)·숨김 서비스 목록(admin 토글) + - 디자인 시스템: `--jsm-*` 토큰 체계(slate+딥블루, Pretendard, 상단 네비 기업형) — 구 사이드바/보라 서술 교체 + - 파일 구조 트리: products/outsourcing/track/admin(orders·products)/lib(product-access·product-files·order-emails·request-status·request-emails) 반영 + - 결제: "계좌이체 orders 단일 소스(PG 보류, pay_method 플래그)" 명시 + - 운영 주의에 추가: "마이그레이션은 클라우드+NAS 양쪽 적용", "2026-06-12-products-extend.sql의 pack_files 백필 UPDATE는 재실행 금지" + - 사주 시스템 섹션은 유지하되 상단에 "(현재 숨김 — admin 토글로 복귀 가능)" 1줄 +- [ ] **Step 3:** build + `/music/packs` redirect 확인 + Commit + +```bash +git add next.config.ts CLAUDE.md +git commit -m "chore: music 구매 고아 경로 차단(→/products) + CLAUDE.md 현행화" +``` + +--- + +### Task 11: Phase 3 E2E 검증 + +- [ ] **Step 1: 자동** — `npm test`(10 passed) + `npm run build` + prod 서버 curl: + - `/outsourcing` 200 + 단계형 폼 / `/track/없는토큰` 404 / `/quote/없는토큰` 404(회귀) + - POST `/api/contact` 빈 body 400(회귀) / `/api/admin/quotes/x/send` 비인증 401 + - `/music/packs` → 308 `/products` + - Phase 1·2 회귀: `/` 200, `/products` 200, `/work/saju` 404, `/api/orders` 비로그인 401 +- [ ] **Step 2: 수동 (운영 DB 마이그레이션 적용 후)** — 시나리오 A 전 과정: + 의뢰 폼 4단계 제출 → 고객 접수 메일(추적 링크)+관리자 메일 → /track 타임라인 확인 → admin/contacts에서 검토중 전환·[견적서 작성] → 견적 편집·[고객에게 발송] → 고객 메일 링크로 /quote 열람 → 수락 → /track에 '수주 확정' 반영 + 관리자 수락 메일 / (별건) 거절 → on_hold 반영 +- [ ] **Step 3: 최종 보고** + +--- + +## 운영 노트 + +- **배포 전**: `2026-06-12-client-portal.sql`을 클라우드+NAS 양쪽 적용 (Phase 2와 동일 절차 — heredoc 명령 제공 예정) +- 미적용 상태로 코드만 배포되면: 신규 의뢰 insert가 없는 컬럼으로 실패할 수 있음 → **반드시 선적용**