init: SR System — Helpdesk & ITAM Portal
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { BookOpen, CheckCircle2, Clock, PlayCircle, BarChart3, RefreshCw } from 'lucide-react';
|
||||
import trainingService from '@/services/trainingService';
|
||||
import type { TrainingProgress, TrainingSummary } from '@/types';
|
||||
|
||||
interface TrainingCardProps {
|
||||
courseName: string;
|
||||
progressRate: number;
|
||||
status: string;
|
||||
progressId?: number;
|
||||
onUpdate?: (progressId: number, data: { progressRate?: number; status?: string }) => void;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
NOT_STARTED: { label: '미수강', icon: Clock, color: 'text-text-muted', bg: 'bg-gray-100' },
|
||||
ONGOING: { label: '진행중', icon: PlayCircle, color: 'text-status-pending-text', bg: 'bg-status-pending-bg' },
|
||||
COMPLETED: { label: '완료', icon: CheckCircle2, color: 'text-status-resolved-text', bg: 'bg-status-resolved-bg' },
|
||||
};
|
||||
|
||||
function TrainingCard({ courseName, progressRate, status, progressId, onUpdate }: TrainingCardProps) {
|
||||
const cfg = statusConfig[status as keyof typeof statusConfig] || statusConfig.NOT_STARTED;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
if (!onUpdate || !progressId) return;
|
||||
|
||||
let nextStatus: string;
|
||||
let nextProgress: number;
|
||||
|
||||
if (status === 'NOT_STARTED') {
|
||||
nextStatus = 'ONGOING';
|
||||
nextProgress = 10;
|
||||
} else if (status === 'ONGOING') {
|
||||
nextProgress = Math.min(progressRate + 25, 100);
|
||||
nextStatus = nextProgress === 100 ? 'COMPLETED' : 'ONGOING';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate(progressId, { progressRate: nextProgress, status: nextStatus });
|
||||
}, [onUpdate, progressId, status, progressRate]);
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-2xl border border-border p-5 hover:shadow-soft transition-all">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center text-primary">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${cfg.color} ${cfg.bg}`}>
|
||||
<StatusIcon className="w-3 h-3 mr-1" />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm text-text-main mb-3 line-clamp-2 min-h-[2.5rem]">{courseName}</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>진도율</span>
|
||||
<span className="font-medium text-text-main">{progressRate}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${progressRate}%`,
|
||||
backgroundColor: progressRate === 100 ? '#10B981' : progressRate > 50 ? '#4F46E5' : '#F59E0B',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{status !== 'COMPLETED' && onUpdate && (
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
className="w-full mt-3 text-xs text-primary hover:text-primary-container font-medium flex items-center justify-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
진도 업데이트
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryStats {
|
||||
total: number;
|
||||
completed: number;
|
||||
ongoing: number;
|
||||
notStarted: number;
|
||||
avgProgress: number;
|
||||
}
|
||||
|
||||
function calculateSummary(progresses: TrainingProgress[]): SummaryStats {
|
||||
const total = progresses.length;
|
||||
const completed = progresses.filter((p) => p.status === 'COMPLETED').length;
|
||||
const ongoing = progresses.filter((p) => p.status === 'ONGOING').length;
|
||||
const notStarted = progresses.filter((p) => p.status === 'NOT_STARTED').length;
|
||||
const avgProgress = total > 0 ? Math.round(progresses.reduce((sum, p) => sum + p.progressRate, 0) / total) : 0;
|
||||
|
||||
return { total, completed, ongoing, notStarted, avgProgress };
|
||||
}
|
||||
|
||||
export default function TrainingDashboard() {
|
||||
const [progresses, setProgresses] = useState<TrainingProgress[]>([]);
|
||||
const [summaries, setSummaries] = useState<TrainingSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [progressRes, summaryRes] = await Promise.all([
|
||||
trainingService.getMyProgress(),
|
||||
trainingService.getSummary(),
|
||||
]);
|
||||
|
||||
if (progressRes.success && progressRes.data) {
|
||||
setProgresses(progressRes.data);
|
||||
}
|
||||
if (summaryRes.success && summaryRes.data) {
|
||||
setSummaries(summaryRes.data);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err && typeof err === 'object' && 'message' in err ? (err as { message: string }).message : '데이터를 불러오지 못했습니다';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleUpdateProgress = useCallback(
|
||||
async (progressId: number, data: { progressRate?: number; status?: string }) => {
|
||||
try {
|
||||
const res = await trainingService.update(progressId, data);
|
||||
if (res.success && res.data) {
|
||||
setProgresses((prev) => prev.map((p) => (p.progressId === progressId ? res.data! : p)));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err && typeof err === 'object' && 'message' in err ? (err as { message: string }).message : '진도 업데이트에 실패했습니다';
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const summary = calculateSummary(progresses);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-3xl font-semibold text-text-main tracking-tight">Training</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Track your internal training progress and course completion.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 text-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-text-muted text-sm">교육 데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-3xl font-semibold text-text-main tracking-tight">Training</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Track your internal training progress and course completion.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mx-auto mb-4">
|
||||
<RefreshCw className="w-6 h-6 text-error" />
|
||||
</div>
|
||||
<p className="text-error font-medium mb-2">{error}</p>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="text-primary hover:text-primary-container text-sm font-medium"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-3xl font-semibold text-text-main tracking-tight">Training</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Track your internal training progress and course completion.</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-text-muted">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>
|
||||
평균 진도율 <strong className="text-text-main">{summary.avgProgress}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">전체 과정</p>
|
||||
<p className="font-outfit text-2xl font-semibold text-text-main">{summary.total}</p>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">완료</p>
|
||||
<p className="font-outfit text-2xl font-semibold text-status-resolved-text">{summary.completed}</p>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">진행중</p>
|
||||
<p className="font-outfit text-2xl font-semibold text-status-pending-text">{summary.ongoing}</p>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">미수강</p>
|
||||
<p className="font-outfit text-2xl font-semibold text-text-muted">{summary.notStarted}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summaries.length > 0 && (
|
||||
<div className="bg-surface rounded-2xl border border-border p-5 mb-8">
|
||||
<h3 className="font-outfit text-lg font-semibold text-text-main mb-4">교육 과정별 통계</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 px-4 font-medium text-text-muted uppercase tracking-wider">Course Name</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-text-muted uppercase tracking-wider">Total Enrolled</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-text-muted uppercase tracking-wider">Completed</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-text-muted uppercase tracking-wider">Avg Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summaries.map((s, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0 hover:bg-surface-container-low">
|
||||
<td className="py-3 px-4 text-text-main font-medium">{s.courseName}</td>
|
||||
<td className="py-3 px-4 text-center text-text-muted">{s.totalEnrolled}</td>
|
||||
<td className="py-3 px-4 text-center text-status-resolved-text">{s.completedCount}</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary-fixed text-on-primary-fixed">
|
||||
{s.avgProgress}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progresses.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<BookOpen className="w-12 h-12 text-text-muted mx-auto mb-4 opacity-50" />
|
||||
<p className="text-text-muted text-sm">등록된 교육 과정이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{progresses.map((course) => (
|
||||
<TrainingCard key={course.progressId} {...course} onUpdate={handleUpdateProgress} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user