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:
@@ -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">위의 '기본 7단계 초기화' 버튼으로 표준 단계를 추가하세요</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>
|
||||
);
|
||||
|
||||
56
app/api/admin/milestones/[id]/route.ts
Normal file
56
app/api/admin/milestones/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
72
app/api/admin/milestones/route.ts
Normal file
72
app/api/admin/milestones/route.ts
Normal 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 });
|
||||
}
|
||||
45
app/api/projects/link/route.ts
Normal file
45
app/api/projects/link/route.ts
Normal 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
50
app/api/projects/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user