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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user