feat: 프로젝트 진행 현황 추적 시스템 구축 + 마케팅 카피 강화

[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 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 02:49:40 +09:00
parent 4b712048db
commit 19b09e3b90
11 changed files with 627 additions and 5 deletions

View File

@@ -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:*)"
]
}
}

View File

@@ -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<Milestone[]>([]);
const [mileSaving, setMileSaving] = useState<string | null>(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() {
<div className="border-b border-slate-800 px-8">
<div className="flex gap-0">
{TABS.map((t) => (
<button key={t} onClick={() => setTab(t)}
<button key={t} onClick={() => { setTab(t); if (t === '진행 단계') fetchMilestones(); }}
className={`px-5 py-3 text-sm font-medium border-b-2 transition-all ${tab === t ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-500 hover:text-slate-300'}`}>
{t}
</button>
@@ -491,6 +535,68 @@ export default function QuoteEditorPage() {
</Field>
</div>
)}
{/* ── 진행 단계 ── */}
{tab === '진행 단계' && (
<div className="max-w-2xl space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-white font-bold"> </h3>
<p className="text-slate-500 text-xs mt-0.5"> </p>
</div>
<button
onClick={initDefaultMilestones}
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 border border-blue-600/30 transition-all"
>
7
</button>
</div>
{milestones.length === 0 ? (
<div className="text-center py-12 bg-slate-900 rounded-xl border border-slate-800">
<p className="text-slate-400 text-sm mb-3"> </p>
<p className="text-slate-600 text-xs"> &apos; 7 &apos; </p>
</div>
) : (
<div className="space-y-3">
{milestones.map((m) => (
<div key={m.id} className="bg-slate-900 border border-slate-800 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-3">
<span className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
m.status === 'completed' ? 'bg-emerald-600 text-white' :
m.status === 'in_progress' ? 'bg-blue-600 text-white' :
'bg-slate-700 text-slate-400'
}`}>{m.step_number}</span>
<span className="text-white font-semibold text-sm flex-1">{m.title}</span>
<select
value={m.status}
onChange={(e) => updateMilestone(m.id, 'status', e.target.value)}
disabled={mileSaving === m.id}
className="bg-slate-800 border border-slate-700 text-xs text-white rounded-lg px-2.5 py-1.5 focus:outline-none focus:border-blue-500"
>
<option value="pending"></option>
<option value="in_progress"> </option>
<option value="completed"></option>
</select>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1"> ()</label>
<input
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-blue-500"
value={m.note}
onChange={(e) => updateMilestone(m.id, 'note', e.target.value)}
placeholder="예: 디자인 시안 2종 검토 중, 내일 공유 예정입니다"
/>
</div>
{m.completed_at && (
<p className="text-xs text-emerald-600">: {new Date(m.completed_at).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</p>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);

View File

@@ -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<string, unknown> = {};
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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

50
app/api/projects/route.ts Normal file
View File

@@ -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 });
}

View File

@@ -215,6 +215,17 @@ const guarantees = [
accentText: 'text-violet-400',
accentBorder: 'border-violet-400/20',
},
{
label: '실시간 진행 현황',
detail: '마이페이지에서 7단계 진행 상황 직접 확인',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
accentText: 'text-cyan-400',
accentBorder: 'border-cyan-400/20',
},
];
/* ─── Main Page ─── */
@@ -262,7 +273,7 @@ export default function FreelancePage() {
</div>
{/* 보증 카드 4개 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{guarantees.map((g) => (
<div key={g.label} className={`bg-[#04102b]/60 border ${g.accentBorder} rounded-xl p-4`}>
<div className={`${g.accentText} mb-2`}>{g.icon}</div>

View File

@@ -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<Order[]>([]);
const [lottoHistory, setLottoHistory] = useState<LottoHistoryItem[]>([]);
const [activeSubscriptions, setActiveSubscriptions] = useState<ActiveSubscription[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [linkToken, setLinkToken] = useState('');
const [linking, setLinking] = useState(false);
const [linkMessage, setLinkMessage] = useState('');
// 텔레그램 연동 상태
const [telegramChatId, setTelegramChatId] = useState<string | null>(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 (
<div className="min-h-full flex items-center justify-center bg-[#f0f5ff]">
@@ -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() {
</div>
)}
{/* 프로젝트 진행 현황 */}
{tab === 'projects' && (
<div className="space-y-4">
{projects.length === 0 ? (
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 text-center">
<div className="w-16 h-16 bg-blue-50 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-[#1a56db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 className="font-bold text-[#04102b] text-lg mb-2"> </h3>
<p className="text-slate-500 text-sm mb-6 max-w-sm mx-auto"> .</p>
<Link href="/freelance" className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm transition">
</Link>
</div>
) : (
<div className="space-y-4">
{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 (
<div key={project.id} className="bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden">
{/* 헤더 */}
<div className="bg-[#04102b] px-6 py-4 flex items-center justify-between" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
<div>
<h3 className="font-bold text-white text-base">{project.title}</h3>
<p className="text-blue-300/60 text-xs mt-0.5">
{project.total > 0 ? `${project.total.toLocaleString()}` : '금액 협의 중'} · {new Date(project.created_at).toLocaleDateString('ko-KR')}
</p>
</div>
<span className={`text-xs font-bold px-3 py-1.5 rounded-full ${
project.status === 'accepted' ? 'bg-emerald-400/20 text-emerald-300 border border-emerald-400/30' :
project.status === 'in_progress' ? 'bg-blue-400/20 text-blue-300 border border-blue-400/30' :
project.status === 'completed' ? 'bg-violet-400/20 text-violet-300 border border-violet-400/30' :
'bg-slate-400/20 text-slate-300 border border-slate-400/30'
}`}>
{project.status === 'sent' ? '견적 검토 중' :
project.status === 'accepted' ? '계약 완료' :
project.status === 'in_progress' ? '개발 진행 중' :
project.status === 'completed' ? '납품 완료' : project.status}
</span>
</div>
<div className="p-6">
{/* 진행률 바 */}
{totalSteps > 0 && (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-500"> </span>
<span className="text-xs font-bold text-[#1a56db]">{progressPct}% ({completedSteps}/{totalSteps})</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-[#1a56db] rounded-full transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
)}
{/* 현재 진행 단계 */}
{currentStep && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-5">
<div className="flex items-center gap-2 mb-1">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
<span className="text-xs font-bold text-blue-600"> </span>
</div>
<p className="font-bold text-[#04102b] text-sm">{currentStep.title}</p>
{currentStep.note && (
<p className="text-slate-600 text-xs mt-1 leading-relaxed">{currentStep.note}</p>
)}
</div>
)}
{/* 단계별 타임라인 */}
{project.milestones.length > 0 && (
<div className="space-y-2">
{project.milestones.map((m, idx) => (
<div key={m.id} className="flex items-start gap-3">
{/* 아이콘 */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold border-2 ${
m.status === 'completed' ? 'bg-emerald-500 border-emerald-500 text-white' :
m.status === 'in_progress'? 'bg-[#1a56db] border-[#1a56db] text-white' :
'bg-white border-slate-200 text-slate-400'
}`}>
{m.status === 'completed' ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : m.status === 'in_progress' ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3" />
<circle cx="12" cy="12" r="9" />
</svg>
) : m.step_number}
</div>
{/* 수직 연결선 */}
<div className="flex flex-col flex-1 min-w-0" style={{ marginTop: idx === project.milestones.length - 1 ? 0 : undefined }}>
<div className="flex items-center gap-2 py-1">
<span className={`text-sm font-semibold ${
m.status === 'completed' ? 'text-emerald-700' :
m.status === 'in_progress'? 'text-[#1a56db]' :
'text-slate-400'
}`}>{m.title}</span>
{m.status === 'completed' && m.completed_at && (
<span className="text-xs text-slate-400 ml-auto flex-shrink-0">
{new Date(m.completed_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
</span>
)}
</div>
{m.note && m.status !== 'pending' && (
<p className="text-xs text-slate-500 leading-relaxed pb-1">{m.note}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* 견적서 연결 폼 */}
<div className="bg-[#f0f5ff] rounded-2xl border border-[#dbe8ff] p-5">
<p className="text-sm font-bold text-[#04102b] mb-1"> </p>
<p className="text-xs text-slate-500 mb-3"> ? URL .</p>
<form onSubmit={handleLinkProject} className="flex gap-2">
<input
value={linkToken}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={linking || !linkToken.trim()}
className="px-4 py-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white rounded-xl font-semibold text-sm disabled:opacity-50 transition flex-shrink-0"
>
{linking ? '연결 중...' : '연결'}
</button>
</form>
{linkMessage && (
<p className={`text-xs mt-2 font-medium ${linkMessage.includes('연결되었') ? 'text-emerald-600' : 'text-red-500'}`}>
{linkMessage}
</p>
)}
</div>
</div>
)}
{/* 의뢰 내역 */}
{tab === 'orders' && (
<div>

View File

@@ -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 = [

View File

@@ -683,6 +683,10 @@ export default function WebsiteServicePage() {
title: '납기 지연 패널티', desc: '지연 1일당 10만원 자동 감면',
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} style={{ width: 20, height: 20 }}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg>,
},
{
title: '실시간 진행 현황', desc: '마이페이지에서 7단계 진행 상황 직접 확인',
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} style={{ width: 20, height: 20 }}><path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>,
},
].map((b) => (
<div key={b.title} style={{
padding: '20px 22px',

View File

@@ -0,0 +1,53 @@
-- ============================================================
-- 프로젝트 진행 단계 관리 시스템
-- Supabase Dashboard > 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 $$;