feat: add career portfolio page

This commit is contained in:
pray4skylark
2026-06-02 15:00:59 +09:00
parent 770c76127c
commit 37e5c65ac9
+229
View File
@@ -0,0 +1,229 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Building2, BadgeCheck, Calendar, Wrench, Ticket, FileText, Edit3, Save, X, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores';
import employeeService, { type PortfolioEntry } from '@/services/employeeService';
const initialColor = (name: string) => {
const colors = ['from-emerald-400 to-emerald-500', 'from-orange-400 to-orange-500', 'from-blue-400 to-blue-500', 'from-pink-400 to-pink-500', 'from-purple-400 to-purple-500'];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
};
export default function PortfolioPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const [portfolio, setPortfolio] = useState<PortfolioEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editText, setEditText] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!user) return;
setLoading(true);
employeeService.getPortfolio(user.empId)
.then((res) => {
setPortfolio(Array.isArray(res.data) ? res.data : []);
})
.catch(() => setError('포트폴리오를 불러오는데 실패했습니다.'))
.finally(() => setLoading(false));
}, [user]);
const handleEditAchievements = (entry: PortfolioEntry) => {
setEditingId(entry.dispatchId);
setEditText(entry.achievements || '');
};
const handleSaveAchievements = async () => {
if (!user || !editingId) return;
setSaving(true);
try {
await employeeService.updateAchievements(user.empId, editingId, editText);
setPortfolio((prev) => prev.map((p) => p.dispatchId === editingId ? { ...p, achievements: editText } : p));
setEditingId(null);
} catch {
alert('저장에 실패했습니다.');
} finally {
setSaving(false);
}
};
if (!user) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-5xl mx-auto text-center py-20 text-text-muted">로그인이 필요합니다.</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button onClick={() => router.back()} className="p-2 hover:bg-surface-container rounded-xl transition-colors">
<ArrowLeft className="w-5 h-5 text-text-muted" />
</button>
<div>
<h1 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">경력 기술서</h1>
<p className="text-text-muted text-sm mt-0.5">{user.name}님의 포트폴리오</p>
</div>
</div>
{/* Summary */}
{!loading && portfolio.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<div className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Total Projects</p>
<p className="font-outfit text-2xl font-bold text-text-main">{portfolio.length}</p>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Current</p>
<p className="font-outfit text-2xl font-bold text-emerald-500">{portfolio.filter((p) => !p.endDate).length}</p>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">SR Tickets</p>
<p className="font-outfit text-2xl font-bold text-blue-500">{portfolio.reduce((a, p) => a + p.resolvedTickets.length, 0)}</p>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Reports</p>
<p className="font-outfit text-2xl font-bold text-purple-500">{portfolio.reduce((a, p) => a + p.reports.length, 0)}</p>
</div>
</div>
)}
{/* Content */}
{loading ? (
<div className="flex items-center justify-center h-48 text-text-muted text-sm">로딩 중...</div>
) : error ? (
<div className="bg-surface rounded-2xl p-8 text-center border border-border">
<p className="text-status-critical-text">{error}</p>
</div>
) : portfolio.length === 0 ? (
<div className="bg-surface rounded-2xl p-8 sm:p-12 text-center border border-border">
<Building2 className="w-12 h-12 mx-auto mb-4 text-text-muted/40" />
<p className="text-text-muted">등록된 포트폴리오 항목이 없습니다.</p>
</div>
) : (
<div className="relative space-y-0">
{portfolio.map((entry, idx) => (
<div key={entry.dispatchId} className="relative flex gap-4 sm:gap-6 pb-8">
{/* Timeline rail */}
<div className="flex flex-col items-center">
<div className={`w-4 h-4 rounded-full bg-gradient-to-br ${initialColor(entry.projectName)} ring-4 ring-white z-10 flex-shrink-0`} />
{idx < portfolio.length - 1 && <div className="w-0.5 flex-1 bg-gray-200 mt-1" />}
</div>
{/* Card */}
<div className="flex-1 min-w-0 bg-surface rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="px-4 sm:px-6 py-4 flex items-start justify-between gap-3 border-b border-border bg-gradient-to-r from-indigo-50/30 to-transparent">
<div className="min-w-0">
<h3 className="font-outfit font-semibold text-lg text-text-main">{entry.projectName}</h3>
<p className="text-xs text-text-muted mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-center gap-1"><BadgeCheck className="w-3 h-3" />{entry.roleTitle}</span>
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" />{entry.startDate}{entry.endDate ? ` ~ ${entry.endDate}` : ' ~ Present'}</span>
<span>공수 {entry.contributionRate}%</span>
</p>
</div>
<span className="text-xs bg-primary/10 text-primary px-2.5 py-1 rounded-full font-medium shrink-0">{entry.contributionRate}%</span>
</div>
{/* Body */}
<div className="px-4 sm:px-6 py-4 space-y-4">
{/* Tech Stack */}
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1.5">Tech Stack</p>
<div className="flex flex-wrap gap-1.5">
{entry.individualTech.split(',').map((tech) => (
<span key={tech.trim()} className="text-xs bg-surface-container text-text-main px-2.5 py-1 rounded-lg font-medium">{tech.trim()}</span>
))}
</div>
</div>
{/* Performed Tasks */}
{entry.performedTasks && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Performed Tasks</p>
<p className="text-sm text-text-main leading-relaxed whitespace-pre-wrap">{entry.performedTasks}</p>
</div>
)}
{/* Achievements */}
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Achievements</p>
{editingId === entry.dispatchId ? (
<div className="space-y-2">
<textarea value={editText} onChange={(e) => setEditText(e.target.value)} rows={4}
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main placeholder-text-muted text-sm focus:outline-none focus:ring-2 focus:ring-primary/20" />
<div className="flex gap-2">
<button onClick={handleSaveAchievements} disabled={saving}
className="flex items-center gap-1.5 bg-primary text-white px-4 py-2 rounded-xl text-xs font-semibold hover:bg-primary-hover transition-colors disabled:opacity-50">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Save
</button>
<button onClick={() => setEditingId(null)}
className="flex items-center gap-1.5 bg-gray-100 text-text-muted px-4 py-2 rounded-xl text-xs font-semibold hover:bg-gray-200 transition-colors">
<X className="w-3.5 h-3.5" /> Cancel
</button>
</div>
</div>
) : (
<div className="group relative">
<p className="text-sm text-text-main leading-relaxed whitespace-pre-wrap">{entry.achievements || '-'}</p>
<button onClick={() => handleEditAchievements(entry)}
className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 p-1.5 bg-surface-container hover:bg-surface-container-high rounded-lg transition-all">
<Edit3 className="w-3.5 h-3.5 text-text-muted" />
</button>
</div>
)}
</div>
{/* Linked SR Tickets */}
{entry.resolvedTickets.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Ticket className="w-3 h-3" /> Resolved SR Tickets ({entry.resolvedTickets.length})
</p>
<div className="space-y-1.5">
{entry.resolvedTickets.map((t) => (
<div key={t.ticketId} className="flex items-start gap-2 text-sm bg-surface-container-low rounded-lg px-3 py-2">
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded font-medium shrink-0">{t.category}</span>
<span className="text-text-main">{t.title}</span>
</div>
))}
</div>
</div>
)}
{/* Linked Reports */}
{entry.reports.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<FileText className="w-3 h-3" /> Reports ({entry.reports.length})
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{entry.reports.slice(-4).map((r) => (
<div key={r.targetDate} className="flex items-center gap-2 text-xs bg-surface-container-low rounded-lg px-3 py-2">
<span className="text-text-muted">{r.targetDate}</span>
<span className="text-text-main truncate">{r.reportType}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}