feat: add career portfolio page
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user