From 19b09e3b9054d316e7825fc2a7df23947f0522f5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Apr 2026 02:49:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=ED=98=84=ED=99=A9=20=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=EC=B6=95=20+=20?= =?UTF-8?q?=EB=A7=88=EC=BC=80=ED=8C=85=20=EC=B9=B4=ED=94=BC=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [DB] - supabase/migrations/002_project_milestones.sql 추가 quotes.user_id 컬럼 + project_milestones 테이블 생성 SQL [API] - GET /api/projects — 로그인 사용자의 프로젝트+마일스톤 조회 - POST /api/projects/link — 견적서 토큰으로 계정에 프로젝트 연결 - GET/POST /api/admin/milestones — 관리자 마일스톤 목록/기본 7단계 초기화 - PATCH/DELETE /api/admin/milestones/[id] — 관리자 단계별 상태·메모 업데이트 [UI — 마이페이지] - '프로젝트 현황' 탭 신규 추가 (Tab type 확장) - 진행률 바, 단계별 타임라인, 개발자 메모 표시 - 견적서 코드 입력 → 계정 연결 폼 [UI — 관리자 견적서 편집] - '진행 단계' 탭 추가: 기본 7단계 초기화, 단계별 status/메모 편집 [마케팅 카피] - page.tsx PROMISES 4번째 추가: "진행 현황 마이페이지 실시간 확인" - freelance 보증 카드 5번째 추가: 실시간 진행 현황 (그리드 2×5) - services/website trust badge 5번째 추가 Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 4 +- app/admin/quotes/[id]/page.tsx | 110 ++++++++- app/api/admin/milestones/[id]/route.ts | 56 +++++ app/api/admin/milestones/route.ts | 72 ++++++ app/api/projects/link/route.ts | 45 ++++ app/api/projects/route.ts | 50 ++++ app/freelance/page.tsx | 13 +- app/mypage/page.tsx | 217 +++++++++++++++++- app/page.tsx | 8 + app/services/website/page.tsx | 4 + .../migrations/002_project_milestones.sql | 53 +++++ 11 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 app/api/admin/milestones/[id]/route.ts create mode 100644 app/api/admin/milestones/route.ts create mode 100644 app/api/projects/link/route.ts create mode 100644 app/api/projects/route.ts create mode 100644 supabase/migrations/002_project_milestones.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3a89814..1ca8ebd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,9 @@ "Read(//c/Users/jaeoh/.claude/skills/taste-skill//**)", "Read(//c/Users/jaeoh/.claude/skills/soft-skill//**)", "Bash(git push:*)", - "WebFetch(domain:jaengseung-made.com)" + "WebFetch(domain:jaengseung-made.com)", + "Bash(npx vercel:*)", + "Bash(1:*)" ] } } diff --git a/app/admin/quotes/[id]/page.tsx b/app/admin/quotes/[id]/page.tsx index e8c3917..9943218 100644 --- a/app/admin/quotes/[id]/page.tsx +++ b/app/admin/quotes/[id]/page.tsx @@ -32,9 +32,19 @@ const STATUS_OPTIONS = [ const ITEM_CATEGORIES = ['기획', '디자인', '개발', '인프라', '유지보수', '기타']; -const TABS = ['기본정보', 'WBS', '견적항목', '향후관리', '특이사항'] as const; +const TABS = ['기본정보', 'WBS', '견적항목', '향후관리', '특이사항', '진행 단계'] as const; type Tab = typeof TABS[number]; +interface Milestone { + id: string; + step_number: number; + title: string; + description: string; + status: 'pending' | 'in_progress' | 'completed'; + note: string; + completed_at: string | null; +} + /* ─── 컴포넌트 ─────────────────────────────────────────── */ export default function QuoteEditorPage() { const params = useParams(); @@ -52,6 +62,8 @@ export default function QuoteEditorPage() { const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [copied, setCopied] = useState(false); + const [milestones, setMilestones] = useState([]); + const [mileSaving, setMileSaving] = useState(null); useEffect(() => { fetch(`/api/admin/quotes/${id}`) @@ -81,6 +93,38 @@ export default function QuoteEditorPage() { if (!silent) { setSaving(false); setSaved(true); setTimeout(() => setSaved(false), 2000); } }, [id, form]); + // ── Milestones ────────────────────────── + async function fetchMilestones() { + const res = await fetch(`/api/admin/milestones?quoteId=${id}`); + const d = await res.json(); + setMilestones(d.milestones ?? []); + } + + async function initDefaultMilestones() { + if (!confirm('기존 단계를 삭제하고 기본 7단계로 초기화할까요?')) return; + const res = await fetch('/api/admin/milestones', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ useDefaults: true, quoteId: id }), + }); + const d = await res.json(); + setMilestones(d.milestones ?? []); + } + + async function updateMilestone(mid: string, field: string, value: string) { + setMileSaving(mid); + const res = await fetch(`/api/admin/milestones/${mid}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: value }), + }); + const d = await res.json(); + if (d.milestone) { + setMilestones((prev) => prev.map((m) => m.id === mid ? d.milestone : m)); + } + setMileSaving(null); + } + // ── helpers ──────────────────────────── const setField = (k: keyof QuoteForm, v: unknown) => setForm((f) => ({ ...f, [k]: v })); @@ -224,7 +268,7 @@ export default function QuoteEditorPage() {
{TABS.map((t) => ( - @@ -491,6 +535,68 @@ export default function QuoteEditorPage() {
)} + + {/* ── 진행 단계 ── */} + {tab === '진행 단계' && ( +
+
+
+

프로젝트 진행 단계 관리

+

고객 마이페이지에 실시간으로 표시됩니다

+
+ +
+ + {milestones.length === 0 ? ( +
+

진행 단계가 없습니다

+

위의 '기본 7단계 초기화' 버튼으로 표준 단계를 추가하세요

+
+ ) : ( +
+ {milestones.map((m) => ( +
+
+ {m.step_number} + {m.title} + +
+
+ + updateMilestone(m.id, 'note', e.target.value)} + placeholder="예: 디자인 시안 2종 검토 중, 내일 공유 예정입니다" + /> +
+ {m.completed_at && ( +

완료: {new Date(m.completed_at).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}

+ )} +
+ ))} +
+ )} +
+ )}
); diff --git a/app/api/admin/milestones/[id]/route.ts b/app/api/admin/milestones/[id]/route.ts new file mode 100644 index 0000000..0e7c026 --- /dev/null +++ b/app/api/admin/milestones/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { verifyAdminTokenNode } from '@/lib/admin-auth'; +import { cookies } from 'next/headers'; + +export const runtime = 'nodejs'; + +async function checkAuth() { + const cookieStore = await cookies(); + const token = cookieStore.get('admin_token')?.value; + return token && verifyAdminTokenNode(token); +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const body = await request.json(); + + const ALLOWED = ['status', 'note', 'title', 'description'] as const; + const update: Record = {}; + ALLOWED.forEach((k) => { if (k in body) update[k] = body[k]; }); + + if (body.status === 'completed') { + update.completed_at = new Date().toISOString(); + } else if ('status' in body) { + update.completed_at = null; + } + update.updated_at = new Date().toISOString(); + + const admin = createAdminClient(); + const { data, error } = await admin + .from('project_milestones') + .update(update) + .eq('id', id) + .select() + .single(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ milestone: data }); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { id } = await params; + const admin = createAdminClient(); + const { error } = await admin.from('project_milestones').delete().eq('id', id); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ success: true }); +} diff --git a/app/api/admin/milestones/route.ts b/app/api/admin/milestones/route.ts new file mode 100644 index 0000000..f25c2b2 --- /dev/null +++ b/app/api/admin/milestones/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { verifyAdminTokenNode } from '@/lib/admin-auth'; +import { cookies } from 'next/headers'; + +export const runtime = 'nodejs'; + +const DEFAULT_MILESTONES = [ + { step_number: 1, title: '의뢰 접수', description: '고객 의뢰 및 요구사항 파악 완료' }, + { step_number: 2, title: '계약 체결', description: '계약서 작성 및 계약금 입금' }, + { step_number: 3, title: '기획/와이어프레임', description: '사이트맵·화면 구성·기능 정의' }, + { step_number: 4, title: '디자인 시안', description: 'UI/UX 시안 제작 및 고객 확인' }, + { step_number: 5, title: '개발 진행', description: '프론트·백엔드 구현' }, + { step_number: 6, title: '검수/테스트', description: '기능 검증 및 수정사항 반영' }, + { step_number: 7, title: '납품 완료', description: '소스코드 이관 및 도메인 배포' }, +]; + +async function checkAuth() { + const cookieStore = await cookies(); + const token = cookieStore.get('admin_token')?.value; + return token && verifyAdminTokenNode(token); +} + +export async function GET(request: Request) { + if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const quoteId = searchParams.get('quoteId'); + if (!quoteId) return NextResponse.json({ error: 'quoteId 필요' }, { status: 400 }); + + const admin = createAdminClient(); + const { data, error } = await admin + .from('project_milestones') + .select('*') + .eq('quote_id', quoteId) + .order('step_number', { ascending: true }); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ milestones: data ?? [] }); +} + +export async function POST(request: Request) { + if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json(); + const admin = createAdminClient(); + + // 기본 7단계 초기화 + if (body.useDefaults && body.quoteId) { + await admin.from('project_milestones').delete().eq('quote_id', body.quoteId); + const toInsert = DEFAULT_MILESTONES.map((m) => ({ ...m, quote_id: body.quoteId })); + const { data, error } = await admin.from('project_milestones').insert(toInsert).select(); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ milestones: data }, { status: 201 }); + } + + // 단일 추가 + const { data, error } = await admin + .from('project_milestones') + .insert({ + quote_id: body.quote_id, + step_number: body.step_number ?? 1, + title: body.title ?? '새 단계', + description: body.description ?? '', + status: 'pending', + }) + .select() + .single(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ milestone: data }, { status: 201 }); +} diff --git a/app/api/projects/link/route.ts b/app/api/projects/link/route.ts new file mode 100644 index 0000000..f15ba15 --- /dev/null +++ b/app/api/projects/link/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { createClient } from '@/lib/supabase/server'; + +export const runtime = 'nodejs'; + +export async function POST(request: Request) { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json(); + const token = (body.token as string | undefined)?.trim(); + if (!token) return NextResponse.json({ error: '견적서 코드를 입력해주세요' }, { status: 400 }); + + const admin = createAdminClient(); + + const { data: quote, error } = await admin + .from('quotes') + .select('id, status, user_id, client_email') + .eq('public_token', token) + .single(); + + if (error || !quote) { + return NextResponse.json({ error: '견적서를 찾을 수 없습니다. 코드를 다시 확인해주세요.' }, { status: 404 }); + } + if (quote.status === 'draft') { + return NextResponse.json({ error: '아직 발송되지 않은 견적서입니다.' }, { status: 400 }); + } + if (quote.user_id && quote.user_id !== user.id) { + return NextResponse.json({ error: '이미 다른 계정에 연결된 견적서입니다.' }, { status: 400 }); + } + if (quote.user_id === user.id) { + return NextResponse.json({ success: true, quoteId: quote.id, alreadyLinked: true }); + } + + const { error: updateErr } = await admin + .from('quotes') + .update({ user_id: user.id, updated_at: new Date().toISOString() }) + .eq('id', quote.id); + + if (updateErr) return NextResponse.json({ error: updateErr.message }, { status: 500 }); + + return NextResponse.json({ success: true, quoteId: quote.id }); +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..35aca94 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { createClient } from '@/lib/supabase/server'; + +export const runtime = 'nodejs'; + +export async function GET() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const admin = createAdminClient(); + + const { data: quotes, error: qErr } = await admin + .from('quotes') + .select('id, title, status, items, created_at') + .eq('user_id', user.id) + .in('status', ['sent', 'accepted', 'in_progress', 'completed', 'delivered']) + .order('created_at', { ascending: false }); + + if (qErr) return NextResponse.json({ error: qErr.message }, { status: 500 }); + if (!quotes?.length) return NextResponse.json({ projects: [] }); + + const quoteIds = quotes.map((q) => q.id); + + const { data: milestones } = await admin + .from('project_milestones') + .select('*') + .in('quote_id', quoteIds) + .order('step_number', { ascending: true }); + + const projects = quotes.map((q) => ({ + id: q.id, + title: q.title, + status: q.status, + total: Array.isArray(q.items) + ? q.items.reduce( + (s: number, i: { unitPrice?: number; quantity?: number }) => + s + ((i.unitPrice ?? 0) * (i.quantity ?? 1)), + 0 + ) + : 0, + created_at: q.created_at, + milestones: (milestones ?? []) + .filter((m) => m.quote_id === q.id) + .sort((a, b) => a.step_number - b.step_number), + })); + + return NextResponse.json({ projects }); +} diff --git a/app/freelance/page.tsx b/app/freelance/page.tsx index cfa2ad1..a43c915 100644 --- a/app/freelance/page.tsx +++ b/app/freelance/page.tsx @@ -215,6 +215,17 @@ const guarantees = [ accentText: 'text-violet-400', accentBorder: 'border-violet-400/20', }, + { + label: '실시간 진행 현황', + detail: '마이페이지에서 7단계 진행 상황 직접 확인', + icon: ( + + + + ), + accentText: 'text-cyan-400', + accentBorder: 'border-cyan-400/20', + }, ]; /* ─── Main Page ─── */ @@ -262,7 +273,7 @@ export default function FreelancePage() { {/* 보증 카드 4개 */} -
+
{guarantees.map((g) => (
{g.icon}
diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index 50a60e5..a7604a4 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -15,7 +15,7 @@ function buildSajuResultUrl(rec: SajuRecord) { return url; } -type Tab = 'profile' | 'subscription' | 'lotto' | 'saju' | 'payments' | 'orders'; +type Tab = 'profile' | 'projects' | 'subscription' | 'lotto' | 'saju' | 'payments' | 'orders'; type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting'; interface SajuRecord { @@ -56,6 +56,25 @@ interface LottoHistoryItem { created_at: string; } +interface ProjectMilestone { + id: string; + step_number: number; + title: string; + description: string; + status: 'pending' | 'in_progress' | 'completed'; + note: string; + completed_at: string | null; +} + +interface Project { + id: string; + title: string; + status: string; + total: number; + created_at: string; + milestones: ProjectMilestone[]; +} + interface ActiveSubscription { id: string; product_id: string; @@ -83,6 +102,10 @@ export default function MyPage() { const [orders, setOrders] = useState([]); const [lottoHistory, setLottoHistory] = useState([]); const [activeSubscriptions, setActiveSubscriptions] = useState([]); + const [projects, setProjects] = useState([]); + const [linkToken, setLinkToken] = useState(''); + const [linking, setLinking] = useState(false); + const [linkMessage, setLinkMessage] = useState(''); // 텔레그램 연동 상태 const [telegramChatId, setTelegramChatId] = useState(null); @@ -142,6 +165,13 @@ export default function MyPage() { setActiveSubscriptions(subData.subscriptions ?? []); } + // 프로젝트 진행 현황 조회 + const projRes = await fetch('/api/projects'); + if (projRes.ok) { + const projData = await projRes.json(); + setProjects(projData.projects ?? []); + } + // 로또 히스토리 조회 const { data: history } = await supabase .from('lotto_history') @@ -239,6 +269,32 @@ export default function MyPage() { setTelegramLinkState('idle'); }; + const handleLinkProject = async (e: React.FormEvent) => { + e.preventDefault(); + if (!linkToken.trim()) return; + setLinking(true); + setLinkMessage(''); + try { + const res = await fetch('/api/projects/link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: linkToken.trim() }), + }); + const data = await res.json(); + if (res.ok) { + setLinkMessage('프로젝트가 연결되었습니다!'); + setLinkToken(''); + const projRes = await fetch('/api/projects'); + if (projRes.ok) setProjects((await projRes.json()).projects ?? []); + } else { + setLinkMessage(data.error ?? '연결 중 오류가 발생했습니다.'); + } + } catch { + setLinkMessage('연결 중 오류가 발생했습니다.'); + } + setLinking(false); + }; + if (loading) { return (
@@ -253,6 +309,7 @@ export default function MyPage() { const tabs: { key: Tab; label: string; count?: number }[] = [ { key: 'profile', label: '내 정보' }, + { key: 'projects', label: '프로젝트 현황', count: projects.length || undefined }, { key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined }, { key: 'lotto', label: '로또 기록', count: lottoHistory.length || undefined }, { key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined }, @@ -778,6 +835,164 @@ export default function MyPage() {
)} + {/* 프로젝트 진행 현황 */} + {tab === 'projects' && ( +
+ {projects.length === 0 ? ( +
+
+ + + +
+

진행 중인 프로젝트가 없습니다

+

외주 개발을 의뢰하시면 이곳에서 단계별 진행 현황을 실시간으로 확인할 수 있습니다.

+ + 개발 의뢰하기 → + +
+ ) : ( +
+ {projects.map((project) => { + const totalSteps = project.milestones.length; + const completedSteps = project.milestones.filter((m) => m.status === 'completed').length; + const currentStep = project.milestones.find((m) => m.status === 'in_progress'); + const progressPct = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0; + + return ( +
+ {/* 헤더 */} +
+
+

{project.title}

+

+ {project.total > 0 ? `총 ${project.total.toLocaleString()}원` : '금액 협의 중'} · {new Date(project.created_at).toLocaleDateString('ko-KR')} +

+
+ + {project.status === 'sent' ? '견적 검토 중' : + project.status === 'accepted' ? '계약 완료' : + project.status === 'in_progress' ? '개발 진행 중' : + project.status === 'completed' ? '납품 완료' : project.status} + +
+ +
+ {/* 진행률 바 */} + {totalSteps > 0 && ( +
+
+ 전체 진행률 + {progressPct}% ({completedSteps}/{totalSteps}단계) +
+
+
+
+
+ )} + + {/* 현재 진행 단계 */} + {currentStep && ( +
+
+ + 현재 진행 중 +
+

{currentStep.title}

+ {currentStep.note && ( +

{currentStep.note}

+ )} +
+ )} + + {/* 단계별 타임라인 */} + {project.milestones.length > 0 && ( +
+ {project.milestones.map((m, idx) => ( +
+ {/* 아이콘 */} +
+ {m.status === 'completed' ? ( + + + + ) : m.status === 'in_progress' ? ( + + + + + ) : m.step_number} +
+ + {/* 수직 연결선 */} +
+
+ {m.title} + {m.status === 'completed' && m.completed_at && ( + + {new Date(m.completed_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })} + + )} +
+ {m.note && m.status !== 'pending' && ( +

{m.note}

+ )} +
+
+ ))} +
+ )} +
+
+ ); + })} +
+ )} + + {/* 견적서 연결 폼 */} +
+

견적서 코드로 프로젝트 연결

+

견적서 링크를 받으셨나요? URL 끝의 코드를 입력하면 이 계정에서 진행 현황을 확인할 수 있습니다.

+
+ setLinkToken(e.target.value)} + placeholder="예: abc123xyz" + className="flex-1 px-4 py-2 bg-white border border-[#dbe8ff] rounded-xl text-sm focus:outline-none focus:border-blue-400 min-w-0" + /> + +
+ {linkMessage && ( +

+ {linkMessage} +

+ )} +
+
+ )} + {/* 의뢰 내역 */} {tab === 'orders' && (
diff --git a/app/page.tsx b/app/page.tsx index bccc1c6..9fd97d7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -81,6 +81,14 @@ const PROMISES = [ color: 'border-violet-500/40', accent: 'text-violet-400', }, + { + number: '04', + title: '진행 현황을 마이페이지에서 직접 확인하세요', + detail: '의뢰 접수부터 납품까지 7단계 진행 상황이 고객 마이페이지에 실시간 업데이트됩니다. 오늘 뭐가 진행되는지 물어볼 필요가 없습니다.', + enforce: '단계 전환 시 마이페이지 즉시 반영 보장', + color: 'border-cyan-500/40', + accent: 'text-cyan-400', + }, ]; const LIVE_SERVICES = [ diff --git a/app/services/website/page.tsx b/app/services/website/page.tsx index ce40c26..c02f562 100644 --- a/app/services/website/page.tsx +++ b/app/services/website/page.tsx @@ -683,6 +683,10 @@ export default function WebsiteServicePage() { title: '납기 지연 패널티', desc: '지연 1일당 10만원 자동 감면', icon: , }, + { + title: '실시간 진행 현황', desc: '마이페이지에서 7단계 진행 상황 직접 확인', + icon: , + }, ].map((b) => (
SQL Editor 에서 실행하세요 +-- ============================================================ + +-- 1. quotes 테이블에 user_id 컬럼 추가 +ALTER TABLE quotes + ADD COLUMN IF NOT EXISTS user_id uuid REFERENCES auth.users(id); + +CREATE INDEX IF NOT EXISTS idx_quotes_user_id ON quotes(user_id); + +-- 2. project_milestones 테이블 생성 +CREATE TABLE IF NOT EXISTS project_milestones ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + quote_id uuid NOT NULL REFERENCES quotes(id) ON DELETE CASCADE, + step_number int NOT NULL DEFAULT 1, + title text NOT NULL, + description text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'in_progress', 'completed')), + note text NOT NULL DEFAULT '', + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_project_milestones_quote_id + ON project_milestones(quote_id); + +-- 3. RLS +ALTER TABLE project_milestones ENABLE ROW LEVEL SECURITY; + +-- 로그인 사용자는 자신의 quotes에 연결된 milestones 조회 가능 +CREATE POLICY "Users view own project milestones" + ON project_milestones FOR SELECT TO authenticated + USING ( + quote_id IN (SELECT id FROM quotes WHERE user_id = auth.uid()) + ); + +-- quotes: 사용자가 자신에게 연결된 견적서 조회 가능 +-- (기존 RLS 없는 경우에만 실행) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE tablename = 'quotes' AND policyname = 'Users view own quotes' + ) THEN + ALTER TABLE quotes ENABLE ROW LEVEL SECURITY; + CREATE POLICY "Users view own quotes" + ON quotes FOR SELECT TO authenticated + USING (user_id = auth.uid()); + END IF; +END $$;