init: SR System — Helpdesk & ITAM Portal

This commit is contained in:
pray4skylark
2026-06-02 13:50:39 +09:00
commit 832c9f096f
262 changed files with 30470 additions and 0 deletions
@@ -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>
);
}