feat(phase1): mypage 발주·진행 섹션 — projects API 배선 + 견적코드 연결

견적 수락 시 발주서로 전환되는 프로젝트를 마이페이지에 표면화.
GET /api/projects로 quotes+milestones를 조회해 총액·마일스톤 타임라인을 표시하고,
POST /api/projects/link로 공개 견적 코드를 계정에 연결하는 폼을 추가했다.
기존 requests 탭의 의뢰 카드 리스트는 그대로 유지(탭 key 호환), 라벨만 발주·진행으로 변경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:11:58 +09:00
parent 3db3d91a40
commit 976511df44

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
@@ -87,6 +87,15 @@ interface ProductOrder {
created_at: string; created_at: string;
} }
// 발주·진행 (quotes 기반 — 견적 수락 시 발주서로 전환, /api/projects)
type ProjectMilestone = { quote_id: string; step_number: number; title: string; status: 'pending' | 'in_progress' | 'completed' };
type Project = { id: string; title: string; status: string; total: number; created_at: string; milestones: ProjectMilestone[] };
const QUOTE_STATUS_LABELS: Record<string, string> = {
sent: '견적 발송', accepted: '발주 확정', in_progress: '진행중', completed: '완료', delivered: '납품 완료',
};
const PROJECT_ORDERED_STATUSES = ['accepted', 'in_progress', 'completed', 'delivered'];
function MyPageContent() { function MyPageContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -102,6 +111,13 @@ function MyPageContent() {
// 내 의뢰 탭 — 펼친 카드 id 집합 (기본 접힘) // 내 의뢰 탭 — 펼친 카드 id 집합 (기본 접힘)
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set()); const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
// 발주·진행 (quotes 기반)
const [projects, setProjects] = useState<Project[]>([]);
const [linkCode, setLinkCode] = useState('');
const [linkMsg, setLinkMsg] = useState<string | null>(null);
const [linking, setLinking] = useState(false);
const [showLinkForm, setShowLinkForm] = useState(false);
// 텔레그램 연동 상태 // 텔레그램 연동 상태
const [telegramChatId, setTelegramChatId] = useState<string | null>(null); const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
const [telegramLinkState, setTelegramLinkState] = useState<TelegramLinkState>('idle'); const [telegramLinkState, setTelegramLinkState] = useState<TelegramLinkState>('idle');
@@ -109,6 +125,15 @@ function MyPageContent() {
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>(''); const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
const [showTelegramGuide, setShowTelegramGuide] = useState(false); const [showTelegramGuide, setShowTelegramGuide] = useState(false);
const loadProjects = useCallback(async () => {
try {
const res = await fetch('/api/projects');
if (!res.ok) return;
const d = await res.json();
setProjects(d.projects ?? []);
} catch { /* 미로그인/네트워크 — 무시 */ }
}, []);
useEffect(() => { useEffect(() => {
async function init() { async function init() {
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
@@ -160,10 +185,13 @@ function MyPageContent() {
.limit(50); .limit(50);
setProductOrders(prodOrders || []); setProductOrders(prodOrders || []);
// 발주·진행 (quotes 기반) 조회
await loadProjects();
setLoading(false); setLoading(false);
} }
init(); init();
}, []); }, [loadProjects]);
// ── 텔레그램 연결 ── // ── 텔레그램 연결 ──
const handleTelegramConnect = async () => { const handleTelegramConnect = async () => {
@@ -219,6 +247,24 @@ function MyPageContent() {
}); });
} }
// 견적서 코드 연결 (공개 견적 페이지에서 발급된 public_token)
const handleLink = async () => {
if (!linkCode.trim() || linking) return;
setLinking(true); setLinkMsg(null);
try {
const res = await fetch('/api/projects/link', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: linkCode.trim() }),
});
const d = await res.json();
if (!res.ok) { setLinkMsg(d.error ?? '연결에 실패했습니다.'); return; }
setLinkMsg(d.alreadyLinked ? '이미 연결된 견적서입니다.' : '견적서가 연결되었습니다.');
setLinkCode('');
await loadProjects();
} catch { setLinkMsg('연결에 실패했습니다. 다시 시도해주세요.'); }
finally { setLinking(false); }
};
async function handleDownload(fileId: string) { async function handleDownload(fileId: string) {
setDownloading(fileId); setDownloading(fileId);
try { try {
@@ -260,7 +306,7 @@ function MyPageContent() {
const tabs: { key: Tab; label: string; count?: number }[] = [ const tabs: { key: Tab; label: string; count?: number }[] = [
{ key: 'profile', label: '프로필' }, { key: 'profile', label: '프로필' },
{ key: 'requests', label: '내 의뢰', count: orders.length || undefined }, { key: 'requests', label: '발주·진행', count: orders.length || undefined },
{ key: 'products', label: '내 제품', count: productGroups.length || undefined }, { key: 'products', label: '내 제품', count: productGroups.length || undefined },
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined }, { key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
]; ];
@@ -514,9 +560,71 @@ function MyPageContent() {
</div> </div>
)} )}
{/* ===== 내 의뢰 ===== */} {/* ===== 발주·진행 + 내 의뢰 ===== */}
{tab === 'requests' && ( {tab === 'requests' && (
<div> <div>
{/* 발주·진행 (quotes 기반 — 견적 수락 시 발주서로 전환) */}
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
·
</h2>
<button
type="button"
onClick={() => setShowLinkForm((v) => !v)}
className="text-xs font-semibold transition-colors hover:underline"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
{showLinkForm ? '닫기' : '견적서 코드 연결'}
</button>
</div>
{showLinkForm && (
<div
className="mb-4 rounded-lg border p-4"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<div className="flex items-center gap-2">
<input
type="text"
value={linkCode}
onChange={(e) => setLinkCode(e.target.value)}
placeholder="견적서 코드를 입력하세요"
className="flex-1 min-w-0 px-3 py-2 rounded-lg border text-sm"
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink)' }}
/>
<button
type="button"
onClick={handleLink}
disabled={linking}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)] disabled:opacity-50 flex-shrink-0"
style={{ background: 'var(--jsm-accent)' }}
>
{linking ? '연결중...' : '연결'}
</button>
</div>
{linkMsg && (
<p className="text-xs mt-2 break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{linkMsg}
</p>
)}
</div>
)}
{projects.length === 0 ? (
<p className="text-sm break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
. .
</p>
) : (
<div className="space-y-3">
{projects.map((p) => (
<ProjectCard key={p.id} project={p} />
))}
</div>
)}
</section>
{/* 기존 의뢰 카드 리스트 (contact_requests 기반) */}
{orders.length === 0 ? ( {orders.length === 0 ? (
<EmptyState <EmptyState
title="의뢰 내역이 없습니다" title="의뢰 내역이 없습니다"
@@ -1060,6 +1168,82 @@ function RequestCard({
); );
} }
// 발주서 뱃지 — accepted 이후 상태(발주 확정~납품 완료)에는 "발주서" 뱃지를 병기한다.
function isProjectOrder(status: string): boolean {
return PROJECT_ORDERED_STATUSES.includes(status);
}
// 발주·진행 카드 — quotes 기반. 총액 + 마일스톤 타임라인(스텝 순서대로 진행 상태 표시).
function ProjectCard({ project }: { project: Project }) {
return (
<Card compact>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{project.title}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{isProjectOrder(project.status) && (
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
</span>
)}
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
>
{QUOTE_STATUS_LABELS[project.status] ?? project.status}
</span>
</div>
</div>
<div className="text-sm font-semibold mb-4" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
{project.total.toLocaleString('ko-KR')}
</div>
{project.milestones.length > 0 && (
<ol className="space-y-2">
{project.milestones
.slice()
.sort((a, b) => a.step_number - b.step_number)
.map((m) => {
const done = m.status === 'completed';
const active = m.status === 'in_progress';
return (
<li key={m.step_number} className="flex items-center gap-3">
<span
className="flex items-center justify-center rounded-full text-xs font-semibold flex-shrink-0"
style={{
width: 22,
height: 22,
background: done ? 'var(--jsm-accent)' : 'var(--jsm-surface)',
border: done || active ? '2px solid var(--jsm-accent)' : '2px solid var(--jsm-line)',
color: done ? '#ffffff' : active ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)',
}}
>
{m.step_number}
</span>
<span
className="text-sm break-keep"
style={{
color: done || active ? 'var(--jsm-ink)' : 'var(--jsm-ink-faint)',
fontWeight: active ? 700 : 500,
...KOR_BODY,
}}
>
{m.title}
</span>
</li>
);
})}
</ol>
)}
</Card>
);
}
function EmptyState({ function EmptyState({
title, title,
desc, desc,