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
+3
View File
@@ -0,0 +1,3 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+110
View File
@@ -0,0 +1,110 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { LogIn, Eye, EyeOff, Shield } from 'lucide-react';
import { useAuthStore } from '@/stores';
import type { LoginRequest } from '@/types';
export default function LoginPage() {
const router = useRouter();
const login = useAuthStore((s) => s.login);
const [loginId, setLoginId] = useState('');
const [password, setPassword] = useState('');
const [showPw, setShowPw] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!loginId.trim() || !password.trim()) {
setError('아이디와 비밀번호를 입력해주세요.');
return;
}
setLoading(true);
setError('');
try {
await login({ loginId, password } as LoginRequest);
router.push('/');
} catch (err: unknown) {
const msg = (err as { message?: string })?.message || '로그인에 실패했습니다.';
setError(msg);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#4F46E5] to-[#3525CD] p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-full bg-gradient-to-tr from-[#4F46E5] to-[#712AE2] flex items-center justify-center mx-auto mb-4 shadow-2xl shadow-black/20">
<span className="font-outfit text-2xl font-bold text-white">SR</span>
</div>
<h1 className="font-outfit text-3xl font-semibold text-white">SR System</h1>
<p className="text-white/70 text-sm mt-1">HQ 헬프데스크 포털</p>
</div>
<form onSubmit={handleSubmit} className="bg-white/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-white/20 p-8 space-y-5">
{error && (
<div className="bg-status-critical-bg text-status-critical-text text-sm px-4 py-3 rounded-xl border border-status-critical-text/20">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">아이디</label>
<input
type="text"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
placeholder="아이디를 입력하세요"
className="w-full px-4 py-2.5 rounded-xl border border-border bg-white text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">비밀번호</label>
<div className="relative">
<input
type={showPw ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"
className="w-full px-4 py-2.5 rounded-xl border border-border bg-white text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all pr-10"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPw(!showPw)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main"
>
{showPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-[#4F46E5] to-[#3525CD] hover:from-[#4338CA] hover:to-[#3730A3] text-white font-semibold py-2.5 rounded-xl text-sm transition-all flex items-center justify-center disabled:opacity-60 shadow-lg shadow-primary/30"
>
{loading ? (
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<LogIn className="w-4 h-4 mr-2" />
로그인
</>
)}
</button>
</form>
<p className="text-center text-xs text-white/60 mt-6">
문의: HQ IT Support (내선 1234)
</p>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { Calendar, CalendarOptions } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import { CalendarCheck } from 'lucide-react';
export default function CalendarPage() {
const elRef = useRef<HTMLDivElement>(null);
const calRef = useRef<Calendar | null>(null);
useEffect(() => {
if (!elRef.current || calRef.current) return;
const fetchEvents = async (start: string, end: string) => {
const res = await fetch(`/api/v1/calendar/events?start=${start}&end=${end}`);
const data = await res.json();
if (!data.success) return [];
return (data.data || []).map((e: any) => ({
id: e.id,
title: e.title,
start: e.date,
end: e.endDate || e.date,
backgroundColor: ({ ticket: '#3b82f6', report: '#10b981', dispatch: '#f59e0b' }[e.type as string] || '#6b7280'),
borderColor: ({ ticket: '#3b82f6', report: '#10b981', dispatch: '#f59e0b' }[e.type as string] || '#6b7280'),
textColor: '#fff',
extendedProps: { type: e.type, status: e.status },
}));
};
const cal = new Calendar(elRef.current, {
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
height: 'auto',
locale: 'ko',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek',
},
datesSet: async (info: any) => {
const start = info.start.toISOString().split('T')[0];
const end = info.end.toISOString().split('T')[0];
const events = await fetchEvents(start, end);
cal.removeAllEvents();
cal.addEventSource(events);
},
eventClick: (info: any) => {
const { type, status } = info.event.extendedProps;
alert(`[${type?.toUpperCase()}] ${info.event.title}\nStatus: ${status}`);
},
} as CalendarOptions);
cal.render();
calRef.current = cal;
return () => {
cal.destroy();
calRef.current = null;
};
}, []);
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<CalendarCheck className="w-6 h-6 text-primary" />
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Calendar</h2>
<p className="text-text-muted text-sm">Unified view of tickets, reports, and dispatch schedules.</p>
</div>
</div>
<div className="bg-surface rounded-2xl shadow-card border border-border p-2 sm:p-4">
<div ref={elRef} />
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from './CalendarClient';
+329
View File
@@ -0,0 +1,329 @@
'use client';
import React, { useState, useEffect } from 'react';
import { CalendarCheck, Send, CheckCircle2, ChevronDown, ChevronUp, AlertCircle, FileText, Clock, Eye, PenTool, Ticket } from 'lucide-react';
import { useAuthStore } from '@/stores';
import reportService from '@/services/reportService';
import type { WorkReport } from '@/types';
export default function DailyReportsPage() {
const user = useAuthStore((s) => s.user);
const [reports, setReports] = useState<WorkReport[]>([]);
const [todaySubmitted, setTodaySubmitted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [form, setForm] = useState({ completedTasks: '', plannedTasks: '', issuesAndRisks: '', workHours: '8', blockerFlag: false, artifacts: '' });
const [submitting, setSubmitting] = useState(false);
const [showForm, setShowForm] = useState(false);
const [selectedReport, setSelectedReport] = useState<number | null>(null);
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
// Feature 1: SR Auto-complete
const [srTickets, setSrTickets] = useState<any[]>([]);
const [showSrPicker, setShowSrPicker] = useState(false);
const [selectedSrIds, setSelectedSrIds] = useState<Set<number>>(new Set());
const [loadingSr, setLoadingSr] = useState(false);
// Feature 2: Manager Review Tab
const [pendingReports, setPendingReports] = useState<any[]>([]);
const [reviewTab, setReviewTab] = useState<'my' | 'pending'>('my');
const today = new Date().toISOString().split('T')[0];
const fetchReports = () => {
if (!user) return;
setLoading(true);
reportService.getAll({ reportType: 'DAILY', size: 50 }).then((res) => {
const list = res.data.content || [];
setReports(list);
setTodaySubmitted(list.some((r: any) => r.targetDate === today));
}).catch(() => setError('Failed to load reports'))
.finally(() => setLoading(false));
};
useEffect(() => { fetchReports(); }, [user]);
// Feature 2: Fetch pending reports for MANAGER/ADMIN
useEffect(() => {
if (!user || user.roleCode === 'EMPLOYEE') return;
reportService.getAll({ status: 'SUBMITTED', reportType: 'DAILY', size: 50 }).then((res) => {
setPendingReports(res.data.content || []);
}).catch(() => {});
}, [user]);
// Feature 1: SR Auto-complete functions
const fetchSrTickets = async () => {
setLoadingSr(true);
try {
const res = await fetch(`/api/v1/reports/sr-auto-complete?date=${today}`);
const data = await res.json();
if (data.success) setSrTickets(data.data);
setShowSrPicker(true);
} catch {}
setLoadingSr(false);
};
const appendSrToReport = () => {
const selected = srTickets.filter(t => selectedSrIds.has(t.ticketId));
if (selected.length === 0) return;
const srText = selected.map(t => `[#${t.ticketId}] ${t.title}${t.resolutionNote ? ` → ${t.resolutionNote}` : ''}`).join('\n');
setForm(f => ({ ...f, completedTasks: f.completedTasks + (f.completedTasks ? '\n\n[Resolved SR Tickets]\n' : '[Resolved SR Tickets]\n') + srText }));
setShowSrPicker(false);
};
// Feature 2: Manager review function
const handleReview = async (reportId: number, status: 'APPROVED' | 'REJECTED') => {
const comment = status === 'REJECTED' ? prompt('Rejection reason:') : '';
if (status === 'REJECTED' && !comment) return;
try {
await reportService.review(reportId, status, comment || '');
fetchReports(); // refresh both lists
// Re-fetch pending reports
if (user && user.roleCode !== 'EMPLOYEE') {
reportService.getAll({ status: 'SUBMITTED', reportType: 'DAILY', size: 50 }).then((res) => {
setPendingReports(res.data.content || []);
}).catch(() => {});
}
} catch { alert('Review failed.'); }
};
const handleSubmit = async () => {
if (!form.completedTasks.trim()) return;
setSubmitting(true);
try {
await reportService.create({
reportType: 'DAILY',
targetDate: today,
completedTasks: form.completedTasks,
plannedTasks: form.plannedTasks,
issuesAndRisks: form.issuesAndRisks,
status: 'SUBMITTED',
workHours: form.workHours ? parseFloat(form.workHours) : null,
blockerFlag: form.blockerFlag ? 'Y' : 'N',
artifacts: form.artifacts ? JSON.stringify([{ type: 'note', description: form.artifacts }]) : '[]',
});
setForm({ completedTasks: '', plannedTasks: '', issuesAndRisks: '', workHours: '8', blockerFlag: false, artifacts: '' });
setShowForm(false);
fetchReports();
} catch { alert('Failed to submit.'); }
setSubmitting(false);
};
const todayReport = reports.find((r: any) => r.targetDate === today);
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div className="flex items-center gap-3">
<CalendarCheck className="w-6 h-6 text-primary" />
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Daily Reports</h2>
<p className="text-text-muted text-sm">Track your daily work logs</p>
</div>
</div>
{!showForm && !todaySubmitted && (
<button onClick={() => setShowForm(true)} className="bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold shadow-soft transition-all flex items-center gap-2">
<PenTool className="w-4 h-4" /> Write Today's Report
</button>
)}
</div>
{/* Tab switcher for MANAGER/ADMIN */}
{user && user.roleCode !== 'EMPLOYEE' && (
<div className="flex gap-2 mb-4">
<button onClick={() => setReviewTab('my')} className={`px-4 py-2 rounded-xl text-xs font-medium transition-all ${reviewTab === 'my' ? 'bg-primary text-white' : 'bg-gray-100 text-text-muted hover:bg-gray-200'}`}>My Reports</button>
<button onClick={() => setReviewTab('pending')} className={`px-4 py-2 rounded-xl text-xs font-medium transition-all ${reviewTab === 'pending' ? 'bg-primary text-white' : 'bg-gray-100 text-text-muted hover:bg-gray-200'} relative`}>
Pending Reviews
{pendingReports.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 bg-red-500 text-white text-[9px] rounded-full">{pendingReports.length}</span>}
</button>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-100 text-red-600 px-4 py-3 rounded-xl text-sm mb-6 flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> {error}
</div>
)}
{/* Today's report status / write form */}
<div className="bg-surface rounded-2xl shadow-card border border-border overflow-hidden mb-6">
<div className="px-4 sm:px-6 py-4 border-b border-border flex items-center justify-between bg-gradient-to-r from-indigo-50/30 to-transparent">
<div className="flex items-center gap-2">
<Clock className="w-4 h-5 text-primary" />
<h3 className="font-outfit font-semibold text-lg text-text-main">
{todayReport ? "Today's Report" : 'New Daily Report'}
</h3>
</div>
{todaySubmitted && (
<span className="flex items-center gap-1.5 text-xs font-medium text-status-resolved-text bg-status-resolved-bg px-3 py-1 rounded-full">
<CheckCircle2 className="w-3.5 h-3.5" /> Submitted
</span>
)}
</div>
{showForm && !todaySubmitted ? (
<div className="p-4 sm:p-6 space-y-4">
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">Today's date: <span className="text-text-main font-semibold">{today}</span></label>
<textarea value={form.completedTasks} onChange={(e) => setForm(f => ({ ...f, completedTasks: e.target.value }))}
placeholder="What did you work on today? (required)"
className="w-full px-3 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none" rows={3} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">Plans for tomorrow</label>
<textarea value={form.plannedTasks} onChange={(e) => setForm(f => ({ ...f, plannedTasks: e.target.value }))}
placeholder="Next steps..."
className="w-full px-3 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none" rows={2} />
</div>
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">Issues / Risks</label>
<textarea value={form.issuesAndRisks} onChange={(e) => setForm(f => ({ ...f, issuesAndRisks: e.target.value }))}
placeholder="Blockers or help needed..."
className="w-full px-3 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none" rows={2} />
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">Work Hours</label>
<input type="number" value={form.workHours} onChange={(e) => setForm(f => ({ ...f, workHours: e.target.value }))}
min="0" max="24" step="0.5"
className="w-full px-3 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary" />
</div>
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">Has Blocker?</label>
<label className="flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border text-sm cursor-pointer hover:bg-gray-50">
<input type="checkbox" checked={form.blockerFlag} onChange={(e) => setForm(f => ({ ...f, blockerFlag: e.target.checked }))} className="rounded" />
<span className="text-sm text-text-main">{form.blockerFlag ? 'Yes' : 'No'}</span>
</label>
</div>
<div>
<label className="text-xs font-medium text-text-muted mb-1.5 block">Artifacts (link / note)</label>
<input value={form.artifacts} onChange={(e) => setForm(f => ({ ...f, artifacts: e.target.value }))}
placeholder="Git commit, doc link..."
className="w-full px-3 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary" />
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => setShowForm(false)} className="px-5 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100 transition-all">Cancel</button>
<button onClick={handleSubmit} disabled={submitting || !form.completedTasks.trim()}
className="bg-primary hover:bg-primary-hover text-white px-6 py-2 rounded-xl text-sm font-semibold transition-all flex items-center gap-2 disabled:opacity-50">
<Send className="w-4 h-4" /> {submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
) : todayReport ? (
<div className="p-4 sm:p-6">
<div className="bg-status-resolved-bg/40 rounded-xl p-4 sm:p-5 space-y-3">
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Completed</p>
<p className="text-sm text-text-main whitespace-pre-line">{todayReport.completedTasks || '-'}</p>
</div>
{todayReport.plannedTasks && <div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Planned</p>
<p className="text-sm text-text-main whitespace-pre-line">{todayReport.plannedTasks}</p>
</div>}
{todayReport.issuesAndRisks && <div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Issues</p>
<p className="text-sm text-text-main whitespace-pre-line">{todayReport.issuesAndRisks}</p>
</div>}
{todayReport.workHours && <div className="flex gap-4 text-xs text-text-muted">
<span>⏱ {todayReport.workHours}h</span>
{todayReport.blockerFlag === 'Y' && <span className="text-status-critical-text font-semibold">🚫 Blocker</span>}
</div>}
</div>
</div>
) : (
<div className="p-8 text-center text-text-muted text-sm">
<CalendarCheck className="w-8 h-8 mx-auto mb-2 text-text-muted/40" />
<p>No report submitted today.</p>
</div>
)}
</div>
{/* Report history */}
<div className="bg-surface rounded-2xl shadow-card border border-border overflow-hidden">
<div className="px-4 sm:px-6 py-4 border-b border-border">
<h3 className="font-outfit font-semibold text-lg text-text-main">Report History</h3>
</div>
{loading ? (
<div className="p-8 text-center text-text-muted text-sm">Loading...</div>
) : reviewTab === 'pending' ? (
pendingReports.length === 0 ? (
<div className="p-8 text-center text-text-muted text-sm">
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-text-muted/40" />
<p>No pending reports for review.</p>
</div>
) : (
<div className="divide-y divide-border">
{pendingReports.map((r: any) => (
<div key={r.reportId} className="px-4 sm:px-6 py-4 hover:bg-gray-50/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-text-muted font-medium">{r.user?.name || `User #${r.user?.empId}`}</span>
<span className="text-xs text-gray-300">|</span>
<span className="text-sm font-semibold text-text-main">{r.targetDate}</span>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-status-pending-bg text-status-pending-text">SUBMITTED</span>
</div>
<p className="text-sm text-text-main line-clamp-2">{r.completedTasks}</p>
</div>
</div>
<div className="flex gap-2 mt-3">
<button onClick={() => handleReview(r.reportId, 'APPROVED')} className="px-4 py-2 bg-status-resolved-bg text-status-resolved-text rounded-xl text-xs font-semibold hover:bg-status-resolved-text hover:text-white transition-all">Approve</button>
<button onClick={() => handleReview(r.reportId, 'REJECTED')} className="px-4 py-2 bg-status-critical-bg text-status-critical-text rounded-xl text-xs font-semibold hover:bg-status-critical-text hover:text-white transition-all">Reject</button>
</div>
</div>
))}
</div>
)
) : reports.length === 0 ? (
<div className="p-8 text-center text-text-muted text-sm">
<FileText className="w-8 h-8 mx-auto mb-2 text-text-muted/40" />
<p>No daily reports yet.</p>
</div>
) : (
<div className="divide-y divide-border">
{reports.map((r: any) => (
<div key={r.reportId} className="px-4 sm:px-6 py-4 hover:bg-gray-50/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-text-main">{r.targetDate}</span>
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
r.status === 'SUBMITTED' || r.status === 'APPROVED'
? 'bg-status-resolved-bg text-status-resolved-text'
: 'bg-status-pending-bg text-status-pending-text'
}`}>{r.status}</span>
</div>
<p className="text-sm text-text-main line-clamp-2">{r.completedTasks}</p>
</div>
<button onClick={() => setSelectedReport(selectedReport === r.reportId ? null : r.reportId)} className="flex-shrink-0 p-2 text-text-muted hover:text-primary transition-colors">
{selectedReport === r.reportId ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
{selectedReport === r.reportId && (
<div className="mt-3 pt-3 border-t border-border space-y-3 text-sm">
<div><span className="text-xs font-semibold text-text-muted">Completed:</span><p className="text-text-main mt-0.5 whitespace-pre-line">{r.completedTasks}</p></div>
{r.plannedTasks && <div><span className="text-xs font-semibold text-text-muted">Planned:</span><p className="text-text-main mt-0.5 whitespace-pre-line">{r.plannedTasks}</p></div>}
{r.issuesAndRisks && <div><span className="text-xs font-semibold text-text-muted">Issues:</span><p className="text-text-main mt-0.5 whitespace-pre-line">{r.issuesAndRisks}</p></div>}
{r.reviewComment && <div><span className="text-xs font-semibold text-text-muted">Review:</span><p className="text-text-main mt-0.5">{r.reviewComment}</p></div>}
{user && user.roleCode !== 'EMPLOYEE' && r.status === 'SUBMITTED' && (
<div className="flex gap-2 pt-2">
<button onClick={() => handleReview(r.reportId, 'APPROVED')} className="px-4 py-2 bg-status-resolved-bg text-status-resolved-text rounded-xl text-xs font-semibold hover:bg-status-resolved-text hover:text-white transition-all">Approve</button>
<button onClick={() => handleReview(r.reportId, 'REJECTED')} className="px-4 py-2 bg-status-critical-bg text-status-critical-text rounded-xl text-xs font-semibold hover:bg-status-critical-text hover:text-white transition-all">Reject</button>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Search, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores';
import dispatchHistoryService from '@/services/dispatchHistoryService';
import DispatchFormModal from '@/components/dispatch/DispatchFormModal';
import type { DispatchHistory } from '@/types';
export default function DispatchManagePage() {
const user = useAuthStore((s) => s.user);
const [items, setItems] = useState<DispatchHistory[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editData, setEditData] = useState<Record<string, unknown> | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const fetchData = () => {
dispatchHistoryService.getAll({ size: 100 }).then((res) => {
setItems(res.data.content || []);
}).catch(() => {
console.error('Failed to fetch dispatches');
}).finally(() => {
setLoading(false);
});
};
useEffect(() => { fetchData(); }, []);
const handleDelete = async (id: number) => {
try {
await dispatchHistoryService.deleteDispatch(id);
setDeleteConfirm(null);
fetchData();
} catch {
alert('Failed to delete dispatch.');
}
};
if (!user || user.roleCode === 'EMPLOYEE') {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto text-center py-20">
<AlertCircle className="w-12 h-12 text-text-muted mx-auto mb-4" />
<h2 className="text-xl font-semibold text-text-main mb-2">Access Denied</h2>
<p className="text-text-muted text-sm">Only managers and admins can access this page.</p>
</div>
</div>
);
}
const filtered = items.filter((item) =>
item.projectName?.toLowerCase().includes(search.toLowerCase()) ||
item.roleTitle?.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main">Dispatch Management</h2>
<p className="text-text-muted text-sm mt-1">Manage employee project assignments</p>
</div>
<button
onClick={() => { setEditData(null); setModalOpen(true); }}
className="bg-primary hover:bg-primary-hover text-white px-4 py-2 rounded-xl text-sm font-semibold flex items-center gap-2 transition-all"
>
<Plus className="w-4 h-4" />
New Dispatch
</button>
</div>
<div className="bg-surface rounded-2xl shadow-card border border-border overflow-hidden">
<div className="p-4 sm:p-6 border-b border-border">
<div className="relative w-full sm:w-72">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by project or role..."
className="w-full pl-9 pr-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50/50">
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Project</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Role</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Employee</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Period</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Actions</th>
</tr>
</thead>
<tbody className="text-sm divide-y divide-gray-100">
{loading && <tr><td colSpan={5} className="py-8 text-center text-text-muted">Loading...</td></tr>}
{!loading && filtered.length === 0 && (
<tr><td colSpan={5} className="py-8 text-center text-text-muted">No dispatch records found.</td></tr>
)}
{filtered.map((item) => (
<tr key={item.dispatchId} className="hover:bg-gray-50 transition-colors">
<td className="py-4 px-4 sm:px-6 font-medium text-text-main">{item.projectName || `Project #${item.projectId}`}</td>
<td className="py-4 px-4 sm:px-6">
<span className="text-xs font-medium bg-indigo-50 text-primary px-2 py-1 rounded-lg">{item.roleTitle}</span>
</td>
<td className="py-4 px-4 sm:px-6 text-text-muted">Emp #{item.empId}</td>
<td className="py-4 px-4 sm:px-6 text-text-muted">
{item.startDate} ~ {item.endDate || 'Present'}
</td>
<td className="py-4 px-4 sm:px-6">
<div className="flex items-center gap-2">
<button
onClick={() => { setEditData(item as unknown as Record<string, unknown>); setModalOpen(true); }}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
title="Edit"
>
<Pencil className="w-3.5 h-3.5 text-text-muted hover:text-primary" />
</button>
<button
onClick={() => setDeleteConfirm(item.dispatchId)}
className="p-1.5 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5 text-text-muted hover:text-status-critical-text" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<DispatchFormModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSaved={fetchData} editData={editData} />
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setDeleteConfirm(null)}>
<div className="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
<h3 className="font-outfit font-semibold text-lg text-text-main mb-2">Delete Dispatch</h3>
<p className="text-sm text-text-muted mb-4">Are you sure you want to delete this dispatch record?</p>
<div className="flex justify-end gap-3">
<button onClick={() => setDeleteConfirm(null)} className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100 transition-colors">Cancel</button>
<button onClick={() => handleDelete(deleteConfirm)} className="px-4 py-2 rounded-xl text-sm font-semibold bg-status-critical-text text-white hover:bg-red-700 transition-colors">Delete</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+523
View File
@@ -0,0 +1,523 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Building2, Calendar, BadgeCheck, FileText, Ticket, Loader2,
Sparkles, Download, ChevronDown, ChevronUp, MapPin, Plus, X,
Search
} from 'lucide-react';
import { useAuthStore } from '@/stores';
import employeeService, { type PortfolioEntry, type CreateDispatchRequest } from '@/services/employeeService';
import projectService from '@/services/projectService';
import type { EmployeeSearchResult, Project } from '@/types';
function initialColor(name: string) {
const colors = [
'from-primary to-purple-400',
'from-dot-green to-emerald-400',
'from-dot-orange to-amber-400',
'from-dot-blue to-cyan-400',
'from-dot-pink to-rose-400',
];
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];
}
function statusDot(status: string) {
const map: Record<string, string> = {
OPEN: 'bg-status-open-text', ASSIGNED: 'bg-blue-500', IN_PROGRESS: 'bg-status-pending-text',
RESOLVED: 'bg-status-resolved-text', CLOSED: 'bg-status-closed-text',
};
return <span className={`inline-block w-2 h-2 rounded-full ${map[status] || 'bg-gray-300'}`} />;
}
export default function DispatchPage() {
const user = useAuthStore((s) => s.user);
const [portfolio, setPortfolio] = useState<PortfolioEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [generating, setGenerating] = useState<Record<number, boolean>>({});
// Assign modal state
const [showAssignModal, setShowAssignModal] = useState(false);
const [employees, setEmployees] = useState<EmployeeSearchResult[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [empSearch, setEmpSearch] = useState('');
const [selectedEmp, setSelectedEmp] = useState<number | null>(null);
const [selectedProj, setSelectedProj] = useState<number | null>(null);
const [formData, setFormData] = useState({
roleTitle: '',
contributionRate: 100,
individualTech: '',
performedTasks: '',
startDate: new Date().toISOString().split('T')[0]
});
const [submitting, setSubmitting] = useState(false);
const [empSearchTimeout, setEmpSearchTimeout] = useState<NodeJS.Timeout | null>(null);
const fetchPortfolio = () => {
if (!user) return;
setLoading(true);
employeeService.getPortfolio(user.empId)
.then((res) => {
const data = Array.isArray(res.data) ? res.data : [];
setPortfolio(data);
const exp: Record<number, boolean> = {};
data.forEach((p) => { exp[p.dispatchId] = true; });
setExpanded(exp);
})
.catch(() => setError('Failed to load portfolio'))
.finally(() => setLoading(false));
};
useEffect(() => { fetchPortfolio(); }, [user]);
// Fetch employees and projects when modal opens
useEffect(() => {
if (showAssignModal) {
projectService.getAll().then((res) => {
setProjects(res.data || []);
}).catch(console.error);
}
}, [showAssignModal]);
// Employee search with debouncing
useEffect(() => {
if (empSearchTimeout) clearTimeout(empSearchTimeout);
if (!empSearch.trim()) {
setEmployees([]);
return;
}
const timeout = setTimeout(() => {
employeeService.search(empSearch).then((res) => {
setEmployees(res.data || []);
}).catch(console.error);
}, 300);
setEmpSearchTimeout(timeout);
return () => clearTimeout(timeout);
}, [empSearch]);
const handleOpenAssignModal = () => {
setShowAssignModal(true);
setEmployees([]);
setProjects([]);
setSelectedEmp(null);
setSelectedProj(null);
setFormData({
roleTitle: '',
contributionRate: 100,
individualTech: '',
performedTasks: '',
startDate: new Date().toISOString().split('T')[0]
});
};
const handleCloseAssignModal = () => {
setShowAssignModal(false);
setEmployees([]);
setProjects([]);
setSelectedEmp(null);
setSelectedProj(null);
setFormData({
roleTitle: '',
contributionRate: 100,
individualTech: '',
performedTasks: '',
startDate: new Date().toISOString().split('T')[0]
});
};
const handleSubmitAssign = async () => {
if (!selectedEmp || !selectedProj) {
alert('직원과 프로젝트를 선택해주세요.');
return;
}
if (!formData.roleTitle || !formData.individualTech || !formData.performedTasks) {
alert('모든 필드를 입력해주세요.');
return;
}
setSubmitting(true);
try {
const payload: CreateDispatchRequest = {
employeeId: selectedEmp,
projectId: selectedProj,
roleTitle: formData.roleTitle,
contributionRate: formData.contributionRate,
individualTech: formData.individualTech,
performedTasks: formData.performedTasks,
startDate: formData.startDate || undefined
};
await employeeService.createDispatch(payload);
alert('파견이 등록되었습니다.');
handleCloseAssignModal();
fetchPortfolio();
} catch (err) {
console.error(err);
alert('파견 등록에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
const handleAutoComplete = async (entry: PortfolioEntry) => {
if (!user) return;
setGenerating((g) => ({ ...g, [entry.dispatchId]: true }));
const reportSummary = entry.reports.map((r) =>
`[${r.reportType} ${r.targetDate}] ${r.completedTasks}`
).join('\n');
const ticketSummary = entry.resolvedTickets.map((t) =>
`- #${t.ticketId} ${t.title}${t.resolutionNote ? ` → ${t.resolutionNote}` : ''}`
).join('\n');
let achievements = entry.achievements || '';
if (reportSummary) achievements += `\n\n[Reports Summary]\n${reportSummary}`;
if (ticketSummary) achievements += `\n\n[Resolved SR Tickets]\n${ticketSummary}`;
try {
await employeeService.updateAchievements(user.empId, entry.dispatchId, achievements);
setPortfolio((prev) => prev.map((p) =>
p.dispatchId === entry.dispatchId ? { ...p, achievements } : p
));
} catch {
alert('Failed to save achievements. Please try again.');
}
setGenerating((g) => ({ ...g, [entry.dispatchId]: false }));
};
if (loading) {
return (
<div className="p-4 sm:p-6 lg:p-8 flex items-center justify-center min-h-[400px]">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-xl text-sm">{error}</div>
<button onClick={fetchPortfolio} className="mt-3 text-sm text-primary hover:underline">Retry</button>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">
{user?.roleCode === 'EMPLOYEE' ? 'My Portfolio' : 'Dispatch & Portfolio'}
</h2>
<p className="text-text-muted mt-1 text-sm">
{user?.roleCode === 'EMPLOYEE'
? 'Career history with auto-bound report summaries and resolved SR tickets.'
: 'Manage employee dispatch assignments and career portfolios.'}
</p>
</div>
<div className="flex items-center gap-2">
{portfolio.length > 0 && (
<button onClick={() => {
let text = `=== Portfolio for ${user?.name} ===\n\n`;
portfolio.forEach((p) => {
text += `Project: ${p.projectName}\nRole: ${p.roleTitle}\nPeriod: ${p.startDate}${p.endDate ? ` ~ ${p.endDate}` : ' ~ Present'}\nTech: ${p.individualTech}\nAchievements: ${p.achievements || '-'}\n\n`;
});
const blob = new Blob([text], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = `portfolio_${user?.name || 'export'}.txt`; a.click();
URL.revokeObjectURL(a.href);
}} className="flex items-center gap-2 text-xs text-text-muted hover:text-primary transition-colors bg-gray-50 px-3 py-2 rounded-xl">
<Download className="w-4 h-4" />
Export
</button>
)}
{(user?.roleCode === 'MANAGER' || user?.roleCode === 'ADMIN') && (
<button
onClick={handleOpenAssignModal}
className="flex items-center gap-2 bg-primary text-white px-4 py-2 rounded-xl text-sm font-semibold hover:bg-primary-hover transition-colors shadow-sm"
>
<Plus className="w-4 h-4" />
Assign Employee
</button>
)}
</div>
</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">No dispatch history found.</p>
</div>
)}
{/* Timeline */}
<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 shadow-card border border-border overflow-hidden">
<div className="px-4 sm:px-6 py-4 flex flex-col sm:flex-row items-start sm:items-center gap-3 border-b border-border bg-gradient-to-r from-indigo-50/30 to-transparent">
<Building2 className="w-5 h-5 text-primary flex-shrink-0" />
<div className="flex-1 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">{entry.contributionRate}%</span>
</div>
<div className="px-4 sm:px-6 py-4 space-y-3">
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-1">Tech Stack</p>
<div className="flex flex-wrap gap-1.5">
{entry.individualTech.split(',').map((t, i) => (
<span key={i} className="text-xs bg-gray-100 text-text-muted px-2 py-0.5 rounded-md">{t.trim()}</span>
))}
</div>
</div>
<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 whitespace-pre-line">{entry.performedTasks}</p>
</div>
{/* Achievements */}
<div className="bg-amber-50/50 rounded-xl p-4 border border-amber-100/50">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-amber-800 uppercase tracking-wider flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5" />
Achievements
</p>
<button
onClick={() => handleAutoComplete(entry)}
disabled={generating[entry.dispatchId]}
className="flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-hover disabled:opacity-50 transition-colors"
>
<Sparkles className="w-3 h-3" />
{generating[entry.dispatchId] ? 'Generating...' : 'Auto-complete from Reports & SR'}
</button>
</div>
<p className={`text-sm whitespace-pre-line ${entry.achievements ? 'text-text-main' : 'text-text-muted italic'}`}>
{entry.achievements || 'Click auto-complete to generate from reports and resolved SR tickets.'}
</p>
</div>
{/* Bound reports & tickets */}
<button
onClick={() => setExpanded((e) => ({ ...e, [entry.dispatchId]: !e[entry.dispatchId] }))}
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
{expanded[entry.dispatchId] ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
{entry.reports.length} Reports · {entry.resolvedTickets.length} Resolved SR Tickets
</button>
{expanded[entry.dispatchId] && (
<div className="space-y-2 pt-1">
{entry.reports.map((r, i) => (
<div key={i} className="flex items-start gap-2 text-sm bg-gray-50 rounded-lg px-3 py-2">
<FileText className="w-4 h-4 text-text-muted mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-text-muted">{r.reportType} · {r.targetDate}</p>
<p className="text-text-main text-xs mt-0.5 line-clamp-2">{r.completedTasks}</p>
</div>
</div>
))}
{entry.resolvedTickets.map((t, i) => (
<div key={i} className="flex items-start gap-2 text-sm bg-green-50 rounded-lg px-3 py-2">
<Ticket className="w-4 h-4 text-status-resolved-text mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-text-muted flex items-center gap-1.5">
#{t.ticketId} · {t.category}
{statusDot('RESOLVED')}
</p>
<p className="text-text-main text-xs mt-0.5">{t.title}</p>
{t.resolutionNote && <p className="text-status-resolved-text text-xs mt-0.5">→ {t.resolutionNote}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Assign Employee Modal */}
{showAssignModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-surface rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl">
<div className="sticky top-0 bg-surface border-b border-border px-6 py-4 flex items-center justify-between">
<h3 className="font-outfit text-lg font-semibold text-text-main">Assign Employee to Project</h3>
<button onClick={handleCloseAssignModal} className="text-text-muted hover:text-text-main transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-5">
{/* Employee Search */}
<div>
<label className="block text-sm font-medium text-text-muted mb-1.5">Employee</label>
<div className="relative">
<div className="flex items-center gap-2 bg-surface-container-low rounded-xl px-3 py-2.5 border border-border">
<Search className="w-4 h-4 text-text-muted" />
<input
type="text"
value={empSearch}
onChange={(e) => setEmpSearch(e.target.value)}
placeholder="Search by name..."
className="flex-1 bg-transparent text-sm text-text-main placeholder:text-text-muted focus:outline-none"
/>
</div>
{employees.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface border border-border rounded-xl shadow-lg max-h-48 overflow-y-auto z-10">
{employees.map((emp) => (
<button
key={emp.empId}
onClick={() => {
setSelectedEmp(emp.empId);
setEmployees([]);
setEmpSearch(emp.name);
}}
className="w-full px-4 py-2.5 text-left hover:bg-surface-container-low transition-colors flex items-center justify-between"
>
<div>
<p className="text-sm font-medium text-text-main">{emp.name}</p>
<p className="text-xs text-text-muted">{emp.roleCode} · {emp.dispatchStatus}</p>
</div>
{selectedEmp === emp.empId && (
<BadgeCheck className="w-4 h-4 text-primary" />
)}
</button>
))}
</div>
)}
</div>
{selectedEmp && (
<p className="text-xs text-status-resolved-text mt-1 flex items-center gap-1">
<BadgeCheck className="w-3 h-3" />
Employee selected (ID: {selectedEmp})
</p>
)}
</div>
{/* Project Selector */}
<div>
<label className="block text-sm font-medium text-text-muted mb-1.5">Project</label>
<select
value={selectedProj ?? ''}
onChange={(e) => setSelectedProj(Number(e.target.value) || null)}
className="w-full bg-surface-container-low border border-border rounded-xl px-3 py-2.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">Select a project...</option>
{projects.map((proj) => (
<option key={proj.projectId} value={proj.projectId}>
{proj.projectName} ({proj.projectType})
</option>
))}
</select>
</div>
{/* Role Title */}
<div>
<label className="block text-sm font-medium text-text-muted mb-1.5">Role Title</label>
<input
type="text"
value={formData.roleTitle}
onChange={(e) => setFormData({ ...formData, roleTitle: e.target.value })}
placeholder="e.g., Senior Backend Developer"
className="w-full bg-surface-container-low border border-border rounded-xl px-3 py-2.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
{/* Contribution Rate */}
<div>
<label className="block text-sm font-medium text-text-muted mb-1.5">
Contribution Rate: {formData.contributionRate}%
</label>
<input
type="range"
min="0"
max="100"
step="10"
value={formData.contributionRate}
onChange={(e) => setFormData({ ...formData, contributionRate: Number(e.target.value) })}
className="w-full accent-primary"
/>
<div className="flex justify-between text-xs text-text-muted mt-1">
<span>0%</span>
<span>100%</span>
</div>
</div>
{/* Individual Tech */}
<div>
<label className="block text-sm font-medium text-text-muted mb-1.5">Individual Tech Stack</label>
<textarea
value={formData.individualTech}
onChange={(e) => setFormData({ ...formData, individualTech: e.target.value })}
placeholder="e.g., Java, Spring Boot, PostgreSQL, React"
rows={2}
className="w-full bg-surface-container-low border border-border rounded-xl px-3 py-2.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
</div>
{/* Performed Tasks */}
<div>
<label className="block text-sm font-medium text-text-muted mb-1.5">Performed Tasks</label>
<textarea
value={formData.performedTasks}
onChange={(e) => setFormData({ ...formData, performedTasks: e.target.value })}
placeholder="Describe main responsibilities and tasks..."
rows={3}
className="w-full bg-surface-container-low border border-border rounded-xl px-3 py-2.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 resize-none"
/>
</div>
{/* Start Date */}
<div>
<label className="block text-sm font-medium text-text-muted mb-1.5">Start Date</label>
<input
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
className="w-full bg-surface-container-low border border-border rounded-xl px-3 py-2.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
</div>
<div className="sticky bottom-0 bg-surface border-t border-border px-6 py-4 flex justify-end gap-3">
<button
onClick={handleCloseAssignModal}
className="px-4 py-2 text-sm font-medium text-text-muted hover:text-text-main transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmitAssign}
disabled={submitting || !selectedEmp || !selectedProj}
className="px-6 py-2 bg-primary text-white text-sm font-semibold rounded-xl hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
{submitting ? 'Assigning...' : 'Assign Employee'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+841
View File
@@ -0,0 +1,841 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Plus, MoreHorizontal, X, Pencil, Trash2, AlertTriangle } from 'lucide-react';
import employeeService from '@/services/employeeService';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { useAuthStore } from '@/stores';
import type { Employee, RoleCode, EmpStatus, DispatchStatus } from '@/types';
const statusColor: Record<string, string> = {
ACTIVE: 'bg-status-resolved-bg text-status-resolved-text',
ON_LEAVE: 'bg-status-pending-bg text-status-pending-text',
RESIGNED: 'bg-status-critical-bg text-status-critical-text',
};
const initialColors = ['bg-indigo-500', 'bg-purple-500', 'bg-blue-500', 'bg-green-500', 'bg-orange-500', 'bg-pink-500'];
function getInitialColor(name: string, index: number) {
return initialColors[index % initialColors.length];
}
export default function EmployeesPage() {
const [filter, setFilter] = useState('all');
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const { user } = useAuthStore();
const { codes: empStatusCodes, loading: statusCodesLoading } = useCommonCodes('EMP_STATUS');
const { codes: empRoleCodes, loading: roleCodesLoading } = useCommonCodes('EMP_ROLE');
const roleLabel = roleCodesLoading ? {} : Object.fromEntries(empRoleCodes.map((c) => [c.codeValue, c.codeName]));
// Add Employee Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [formData, setFormData] = useState({
loginId: '',
name: '',
email: '',
roleCode: 'EMPLOYEE' as RoleCode,
empStatus: 'ACTIVE' as EmpStatus,
dispatchStatus: 'WAITING' as DispatchStatus,
});
// Edit Modal State
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [editModalError, setEditModalError] = useState<string | null>(null);
const [editData, setEditData] = useState({
name: '',
email: '',
roleCode: 'EMPLOYEE' as RoleCode,
empStatus: 'ACTIVE' as EmpStatus,
dispatchStatus: 'WAITING' as DispatchStatus,
});
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
// Delete Confirmation State
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingEmployee, setDeletingEmployee] = useState<Employee | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const fetchEmployees = async () => {
try {
setLoading(true);
const res = await employeeService.getAll({ size: 100 });
setEmployees(res.data.content);
setError(null);
} catch (err) {
setError('Failed to load employees. Please try again.');
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!user) return;
if (user.roleCode === 'EMPLOYEE') {
setError('Only managers and admins can view employee list.');
setLoading(false);
return;
}
fetchEmployees();
}, [user]);
const handleOpenModal = () => {
setIsModalOpen(true);
setFormData({
loginId: '',
name: '',
email: '',
roleCode: 'EMPLOYEE',
empStatus: 'ACTIVE',
dispatchStatus: 'WAITING',
});
setModalError(null);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setFormData({
loginId: '',
name: '',
email: '',
roleCode: 'EMPLOYEE',
empStatus: 'ACTIVE',
dispatchStatus: 'WAITING',
});
setModalError(null);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.loginId.trim() || !formData.name.trim()) {
setModalError('Login ID and Name are required.');
return;
}
setIsSubmitting(true);
setModalError(null);
try {
await employeeService.create(formData);
alert('Employee added successfully!');
handleCloseModal();
await fetchEmployees();
} catch (err: any) {
setModalError(err.response?.data?.message || 'Failed to add employee. Please try again.');
} finally {
setIsSubmitting(false);
}
};
// Edit handlers
const handleEdit = (emp: Employee) => {
setEditingEmployee(emp);
setEditData({
name: emp.name,
email: emp.email || '',
roleCode: emp.roleCode,
empStatus: emp.empStatus,
dispatchStatus: emp.dispatchStatus,
});
setEditModalError(null);
setIsEditModalOpen(true);
};
const handleCloseEditModal = () => {
setIsEditModalOpen(false);
setEditingEmployee(null);
setEditData({
name: '',
email: '',
roleCode: 'EMPLOYEE',
empStatus: 'ACTIVE',
dispatchStatus: 'WAITING',
});
setEditModalError(null);
};
const handleEditInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setEditData((prev) => ({ ...prev, [name]: value }));
};
const handleUpdateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editData.name.trim()) {
setEditModalError('Name is required.');
return;
}
if (!editingEmployee) return;
setIsUpdating(true);
setEditModalError(null);
try {
await employeeService.update(editingEmployee.empId, editData);
alert('Employee updated successfully!');
handleCloseEditModal();
await fetchEmployees();
} catch (err: any) {
setEditModalError(err.response?.data?.message || 'Failed to update employee. Please try again.');
} finally {
setIsUpdating(false);
}
};
// Delete handlers
const handleDelete = (emp: Employee) => {
setDeletingEmployee(emp);
setIsDeleteDialogOpen(true);
};
const handleCloseDeleteDialog = () => {
setIsDeleteDialogOpen(false);
setDeletingEmployee(null);
};
const handleDeleteConfirm = async () => {
if (!deletingEmployee) return;
setIsDeleting(true);
try {
await employeeService.delete(deletingEmployee.empId);
alert('Employee deleted successfully!');
handleCloseDeleteDialog();
await fetchEmployees();
} catch (err: any) {
alert(err.response?.data?.message || 'Failed to delete employee. Please try again.');
handleCloseDeleteDialog();
} finally {
setIsDeleting(false);
}
};
const [skillKeyword, setSkillKeyword] = useState('');
const [skillResults, setSkillResults] = useState<Employee[]>([]);
useEffect(() => {
if (!skillKeyword.trim()) { setSkillResults([]); return; }
const timer = setTimeout(() => {
employeeService.search(skillKeyword.trim()).then((res) => {
const empIds = new Set(res.data.map((r: any) => r.empId));
setSkillResults(employees.filter((e) => empIds.has(e.empId)));
}).catch(() => {});
}, 300);
return () => clearTimeout(timer);
}, [skillKeyword, employees]);
const displayList = skillKeyword.trim() ? skillResults : employees;
const filtered = filter === 'all' ? displayList : displayList.filter((e) => e.empStatus === filter);
const stats = [
{ label: '전체', value: employees.length, color: 'text-text-main' },
{ label: '재직', value: employees.filter((e) => e.empStatus === 'ACTIVE').length, color: 'text-status-resolved-text' },
{ label: '파견중', value: employees.filter((e) => e.dispatchStatus === 'DISPATCHED').length, color: 'text-status-pending-text' },
{ label: '휴직', value: employees.filter((e) => e.empStatus === 'ON_LEAVE').length, color: 'text-status-critical-text' },
];
// Add Employee Modal
if (isModalOpen) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-6 relative animate-in fade-in zoom-in duration-200">
<button
onClick={handleCloseModal}
className="absolute top-4 right-4 p-1.5 text-text-muted hover:text-text-main hover:bg-gray-100 rounded-lg transition-all"
>
<X className="w-5 h-5" />
</button>
<h3 className="font-outfit text-xl font-semibold text-text-main mb-1">Add Employee</h3>
<p className="text-text-muted text-sm mb-6">Create a new employee account</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
Login ID <span className="text-status-critical-text">*</span>
</label>
<input
type="text"
name="loginId"
value={formData.loginId}
onChange={handleInputChange}
required
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
placeholder="Enter login ID"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
Name <span className="text-status-critical-text">*</span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
required
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
placeholder="Enter full name"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Email</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
placeholder="email@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Role</label>
<select
name="roleCode"
value={formData.roleCode}
onChange={handleInputChange}
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
>
{roleCodesLoading ? (
<option value="">Loading roles...</option>
) : (
empRoleCodes.map((code) => (
<option key={code.codeValue} value={code.codeValue}>
{code.codeName} ({code.codeValue})
</option>
))
)}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Employee Status</label>
<select
name="empStatus"
value={formData.empStatus}
onChange={handleInputChange}
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
>
{statusCodesLoading ? (
<option value="">Loading status...</option>
) : (
empStatusCodes.map((code) => (
<option key={code.codeValue} value={code.codeValue}>
{code.codeName} ({code.codeValue})
</option>
))
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Dispatch Status</label>
<select
name="dispatchStatus"
value={formData.dispatchStatus}
onChange={handleInputChange}
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
>
<option value="WAITING">대기 (WAITING)</option>
<option value="DISPATCHED">파견중 (DISPATCHED)</option>
</select>
</div>
</div>
{modalError && (
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-xl px-4 py-3 text-sm">
{modalError}
</div>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={handleCloseModal}
disabled={isSubmitting}
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-text-main text-sm font-medium hover:bg-gray-100 transition-all disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-white text-sm font-semibold shadow-soft transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Adding...' : 'Add Employee'}
</button>
</div>
</form>
</div>
</div>
);
}
// Edit Employee Modal
if (isEditModalOpen) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-6 relative animate-in fade-in zoom-in duration-200">
<button
onClick={handleCloseEditModal}
className="absolute top-4 right-4 p-1.5 text-text-muted hover:text-text-main hover:bg-gray-100 rounded-lg transition-all"
>
<X className="w-5 h-5" />
</button>
<h3 className="font-outfit text-xl font-semibold text-text-main mb-1">Edit Employee</h3>
<p className="text-text-muted text-sm mb-6">Update employee information</p>
<form onSubmit={handleUpdateSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
Name <span className="text-status-critical-text">*</span>
</label>
<input
type="text"
name="name"
value={editData.name}
onChange={handleEditInputChange}
required
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
placeholder="Enter full name"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Email</label>
<input
type="email"
name="email"
value={editData.email}
onChange={handleEditInputChange}
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
placeholder="email@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Role</label>
<select
name="roleCode"
value={editData.roleCode}
onChange={handleEditInputChange}
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
>
{roleCodesLoading ? (
<option value="">Loading roles...</option>
) : (
empRoleCodes.map((code) => (
<option key={code.codeValue} value={code.codeValue}>
{code.codeName} ({code.codeValue})
</option>
))
)}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Employee Status</label>
<select
name="empStatus"
value={editData.empStatus}
onChange={handleEditInputChange}
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
>
{statusCodesLoading ? (
<option value="">Loading status...</option>
) : (
empStatusCodes.map((code) => (
<option key={code.codeValue} value={code.codeValue}>
{code.codeName} ({code.codeValue})
</option>
))
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Dispatch Status</label>
<select
name="dispatchStatus"
value={editData.dispatchStatus}
onChange={handleEditInputChange}
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
>
<option value="WAITING">대기 (WAITING)</option>
<option value="DISPATCHED">파견중 (DISPATCHED)</option>
</select>
</div>
</div>
{editModalError && (
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-xl px-4 py-3 text-sm">
{editModalError}
</div>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={handleCloseEditModal}
disabled={isUpdating}
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-text-main text-sm font-medium hover:bg-gray-100 transition-all disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isUpdating}
className="flex-1 px-4 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-white text-sm font-semibold shadow-soft transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUpdating ? 'Updating...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
);
}
// Delete Confirmation Dialog
if (isDeleteDialogOpen) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-6 relative animate-in fade-in zoom-in duration-200">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-status-critical-bg flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-status-critical-text" />
</div>
<div>
<h3 className="font-outfit text-xl font-semibold text-text-main">Delete Employee</h3>
<p className="text-text-muted text-sm">This action cannot be undone</p>
</div>
</div>
<div className="bg-gray-50 rounded-xl px-4 py-3 mb-6">
<p className="text-sm text-text-main">
Are you sure you want to delete <span className="font-semibold">{deletingEmployee?.name}</span>?
</p>
<p className="text-xs text-text-muted mt-1">
This will soft-delete the employee record and remove their access to the system.
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={handleCloseDeleteDialog}
disabled={isDeleting}
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-text-main text-sm font-medium hover:bg-gray-100 transition-all disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="flex-1 px-4 py-2.5 rounded-xl bg-status-critical hover:bg-status-critical-hover text-white text-sm font-semibold shadow-soft transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Employees</h2>
<p className="text-text-muted mt-1 text-sm">Manage employee records, dispatch status, and role assignments.</p>
</div>
<div className="h-10 w-32 bg-gray-200 rounded-xl animate-pulse" />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-surface rounded-2xl p-5 border border-border">
<div className="h-3 w-12 bg-gray-200 rounded mb-2 animate-pulse" />
<div className="h-7 w-16 bg-gray-200 rounded animate-pulse" />
</div>
))}
</div>
<div className="bg-surface rounded-2xl border border-border shadow-card overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div className="flex space-x-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-8 w-16 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
<div className="h-9 w-48 bg-gray-200 rounded-lg animate-pulse" />
</div>
<div className="p-6">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center py-4 border-b border-gray-100 last:border-0">
<div className="w-8 h-8 rounded-full bg-gray-200 mr-3 animate-pulse" />
<div className="flex-1 h-4 bg-gray-200 rounded animate-pulse" />
</div>
))}
</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 flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Employees</h2>
<p className="text-text-muted mt-1 text-sm">Manage employee records, dispatch status, and role assignments.</p>
</div>
</div>
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-2xl p-6 sm:p-8 text-center">
<p className="font-medium">{error}</p>
</div>
</div>
</div>
);
}
if (employees.length === 0) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Employees</h2>
<p className="text-text-muted mt-1 text-sm">Manage employee records, dispatch status, and role assignments.</p>
</div>
<button onClick={handleOpenModal} className="w-full sm:w-auto bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold shadow-soft transition-all flex items-center justify-center">
<Plus className="w-4 h-4 mr-2" />
Add Employee
</button>
</div>
<div className="bg-surface rounded-2xl border border-border shadow-card p-12 text-center">
<p className="text-text-muted text-lg">No employees found</p>
</div>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Employees</h2>
<p className="text-text-muted mt-1 text-sm">Manage employee records, dispatch status, and role assignments.</p>
</div>
<button onClick={handleOpenModal} className="w-full sm:w-auto bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold shadow-soft transition-all flex items-center justify-center">
<Plus className="w-4 h-4 mr-2" />
Add Employee
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{stats.map((s) => (
<div key={s.label} className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs text-text-muted font-medium mb-1">{s.label}</p>
<p className={`font-outfit text-2xl font-semibold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
<div className="bg-surface rounded-2xl border border-border shadow-card overflow-hidden">
<div className="px-4 sm:px-6 py-4 border-b border-border flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<button
key="all"
onClick={() => setFilter('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
filter === 'all' ? 'bg-primary text-white' : 'text-text-muted hover:bg-gray-100'
}`}
>
전체
</button>
{statusCodesLoading ? (
<span className="text-xs text-text-muted">Loading...</span>
) : (
empStatusCodes.map((code) => (
<button
key={code.codeValue}
onClick={() => setFilter(code.codeValue)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
filter === code.codeValue ? 'bg-primary text-white' : 'text-text-muted hover:bg-gray-100'
}`}
>
{code.codeName}
</button>
))
)}
</div>
<div className="relative w-full sm:w-56">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
value={skillKeyword}
onChange={(e) => setSkillKeyword(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 rounded-lg bg-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search by name or skill..."
/>
</div>
</div>
<div className="overflow-x-auto">
{/* Desktop table */}
<div className="hidden md:block">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50">
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">이름</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">권한</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">상태</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">이메일</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">액션</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm">
{filtered.map((emp, idx) => (
<tr key={emp.empId} className="hover:bg-gray-50 transition-colors">
<td className="py-4 px-4 sm:px-6">
<div className="flex items-center">
<div className={`w-8 h-8 rounded-full ${getInitialColor(emp.name, idx)} flex items-center justify-center text-xs font-medium text-white mr-3 flex-shrink-0`}>
{emp.name[0]}
</div>
<div className="min-w-0">
<p className="font-medium text-text-main truncate">{emp.name}</p>
<p className="text-xs text-text-muted truncate">{emp.email}</p>
</div>
</div>
</td>
<td className="py-4 px-4 sm:px-6">
<span className="text-xs font-medium text-text-muted">{roleLabel[emp.roleCode] || emp.roleCode}</span>
</td>
<td className="py-4 px-4 sm:px-6">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${statusColor[emp.empStatus]}`}>
{emp.empStatus === 'ACTIVE' ? '재직' : emp.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'}
{emp.dispatchStatus === 'DISPATCHED' && (
<span className="ml-1.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-600 text-[10px]">파견중</span>
)}
</span>
</td>
<td className="py-4 px-4 sm:px-6 text-text-muted truncate max-w-[150px] sm:max-w-none">{emp.email}</td>
<td className="py-4 px-4 sm:px-6">
{user?.roleCode === 'ADMIN' && (
<div className="flex items-center gap-1">
<button
onClick={() => handleEdit(emp)}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
title="Edit"
>
<Pencil className="w-3.5 h-3.5 text-text-muted hover:text-primary" />
</button>
<button
onClick={() => handleDelete(emp)}
className="p-1.5 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5 text-text-muted hover:text-status-critical-text" />
</button>
</div>
)}
{user?.roleCode !== 'ADMIN' && (
<button
onClick={() => router.push(`/profile/${emp.empId}`)}
className="p-1.5 text-text-muted hover:text-primary hover:bg-gray-100 rounded-lg transition-all"
title="View Profile"
>
<MoreHorizontal className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden space-y-3">
{filtered.map((emp, idx) => (
<div key={emp.empId} className="bg-surface rounded-2xl border border-border p-4 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-semibold ${getInitialColor(emp.name, idx)}`}>
{emp.name.charAt(0)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-main truncate">{emp.name}</p>
<p className="text-xs text-text-muted truncate">{roleLabel[emp.roleCode] || emp.roleCode}</p>
</div>
</div>
<span className={`text-[10px] font-medium px-2.5 py-1 rounded-full ${statusColor[emp.empStatus] || 'bg-gray-100 text-gray-600'}`}>
{emp.empStatus === 'ACTIVE' ? '재직' : emp.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-text-muted">
<div className="truncate">Email: {emp.email}</div>
<div>상태: {emp.dispatchStatus === 'DISPATCHED' ? '파견중' : '대기'}</div>
</div>
{user?.roleCode === 'ADMIN' && (
<div className="flex items-center gap-2 pt-2 border-t border-gray-100">
<button
onClick={() => handleEdit(emp)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium text-text-muted hover:bg-gray-100 transition-all"
>
<Pencil className="w-3.5 h-3.5" />
Edit
</button>
<button
onClick={() => handleDelete(emp)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium text-status-critical-text hover:bg-red-50 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
Delete
</button>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
'use client';
import React, { useState, useEffect } from 'react';
import { ChevronLeft, Clock, User, MessageSquare, Paperclip, Send, CheckCircle2, ArrowRight } from 'lucide-react';
import Link from 'next/link';
import ticketService from '@/services/ticketService';
import type { IssueTicket, TicketComment } from '@/types';
const statusFlow = ['OPEN', 'ASSIGNED', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
const statusColor: Record<string, string> = {
OPEN: 'bg-status-open-bg text-status-open-text',
ASSIGNED: 'bg-status-pending-bg text-status-pending-text',
IN_PROGRESS: 'bg-status-pending-bg text-status-pending-text',
RESOLVED: 'bg-status-resolved-bg text-status-resolved-text',
CLOSED: 'bg-gray-100 text-text-muted',
};
const statusLabel: Record<string, string> = {
OPEN: 'Open', ASSIGNED: 'Assigned', IN_PROGRESS: 'In Progress', RESOLVED: 'Resolved', CLOSED: 'Closed',
};
export default function IssueDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = React.use(params);
const ticketId = parseInt(resolvedParams.id);
const [ticket, setTicket] = useState<IssueTicket | null>(null);
const [comments, setComments] = useState<TicketComment[]>([]);
const [newComment, setNewComment] = useState('');
const [status, setStatus] = useState('OPEN');
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
ticketService.getTicket(ticketId),
ticketService.getComments(ticketId),
]).then(([ticketRes, commentsRes]) => {
setTicket(ticketRes.data);
setComments(commentsRes.data);
setStatus(ticketRes.data.status);
}).catch(console.error).finally(() => setLoading(false));
}, [ticketId]);
const currentIdx = statusFlow.indexOf(status);
const canAdvance = currentIdx < statusFlow.length - 1;
const isClosed = status === 'CLOSED';
const handleAdvance = async () => {
if (!canAdvance) return;
const nextStatus = statusFlow[currentIdx + 1];
try {
await ticketService.updateStatus(ticketId, nextStatus);
setStatus(nextStatus);
} catch (err: unknown) {
alert((err as { message?: string })?.message || '상태 변경 실패');
}
};
const handlePostComment = async () => {
if (!newComment.trim()) return;
try {
const res = await ticketService.addComment(ticketId, newComment.trim());
setComments([...comments, res.data]);
setNewComment('');
} catch { alert('댓글 등록 실패'); }
};
if (loading) return <div className="flex items-center justify-center h-screen text-text-muted text-sm">Loading...</div>;
if (!ticket) return <div className="flex items-center justify-center h-screen text-text-muted text-sm">Ticket not found</div>;
return (
<div className="min-h-screen bg-canvas">
<div className="max-w-[1400px] mx-auto p-4 sm:p-6 lg:p-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<Link href="/issues" className="flex items-center text-sm text-text-muted hover:text-text-main transition-colors">
<ChevronLeft className="w-4 h-4 mr-1" /> Back to Issues
</Link>
<div className="flex flex-wrap items-center gap-3">
<span className={`text-xs font-semibold px-3 py-1.5 rounded-full ${statusColor[status]}`}>{statusLabel[status]}</span>
{!isClosed && (
<button onClick={handleAdvance} disabled={!canAdvance} className="bg-primary hover:bg-primary-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-semibold px-4 py-1.5 rounded-lg transition-all flex items-center">
{canAdvance ? <>Advance <ArrowRight className="w-3.5 h-3.5 ml-1.5" /></> : 'Completed'}
</button>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-6">
<h1 className="font-outfit text-xl font-semibold text-text-main mb-4">{ticket.title}</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm mb-4">
<div><span className="text-text-muted text-xs">Project</span><p className="font-medium text-text-main">{ticket.projectName || '-'}</p></div>
<div><span className="text-text-muted text-xs">Category</span><p className="font-medium text-text-main">{ticket.category}</p></div>
<div><span className="text-text-muted text-xs">Priority</span><p className={`font-medium ${ticket.priority === 'CRITICAL' ? 'text-status-critical-text' : 'text-text-main'}`}>{ticket.priority}</p></div>
<div><span className="text-text-muted text-xs">Due</span><p className="font-medium text-text-main flex items-center">{ticket.dueDate ? <><Clock className="w-3.5 h-3.5 mr-1 text-status-critical-text" />{new Date(ticket.dueDate).toLocaleDateString('ko-KR')}</> : '-'}</p></div>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<p className="text-xs text-text-muted mb-1">Description</p>
<p className="text-sm text-text-main whitespace-pre-wrap">{ticket.description}</p>
</div>
</div>
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-6">
<h3 className="font-outfit font-semibold text-sm text-text-main mb-5 flex items-center">
<MessageSquare className="w-4 h-4 mr-2 text-primary" /> Timeline ({comments.length})
</h3>
<div className="space-y-4">
{comments.map((c) => (
<div key={c.commentId} className="flex space-x-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold flex-shrink-0 bg-primary">
{(c.userName || 'U')[0]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<span className="text-xs font-semibold text-text-main">{c.userName || 'User'}</span>
<span className="text-[10px] text-text-muted">{new Date(c.createdAt).toLocaleString('ko-KR')}</span>
</div>
<p className="text-sm text-text-main whitespace-pre-wrap">{c.commentText}</p>
{c.attachedFileUrl && (
<div className="mt-1.5 inline-flex items-center px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-600 text-xs">
<Paperclip className="w-3 h-3 mr-1" /> {c.attachedFileUrl.split('/').pop()}
</div>
)}
</div>
</div>
))}
</div>
{!isClosed && (
<div className="mt-5 pt-4 border-t border-border">
<div className="flex items-start space-x-3">
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">A</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Type your analysis, script, or guidance..."
className="w-full px-4 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
rows={2}
/>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mt-2">
<label className="text-xs text-text-muted hover:text-text-main flex items-center cursor-pointer">
<Paperclip className="w-3.5 h-3.5 mr-1" /> Attach file
<input type="file" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) alert(`File selected: ${f.name}`); e.target.value = '';
}} />
</label>
<button onClick={handlePostComment} className="bg-primary hover:bg-primary-hover text-white text-xs font-semibold px-4 py-2 rounded-lg transition-all flex items-center">
<Send className="w-3.5 h-3.5 mr-1.5" /> Send
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="space-y-6">
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-5">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">Assignment</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted">Requester</span>
<span className="text-xs font-medium text-text-main flex items-center"><User className="w-3 h-3 mr-1.5 text-dot-orange" />{ticket.requesterName || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted">Assignee</span>
<span className="text-xs font-medium text-text-main flex items-center"><User className="w-3 h-3 mr-1.5 text-primary" />{ticket.assigneeName || '미배정'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted">Created</span>
<span className="text-xs font-medium text-text-main">{new Date(ticket.createdAt).toLocaleDateString('ko-KR')}</span>
</div>
</div>
</div>
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-5">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">Progress</h4>
<div className="space-y-3">
{statusFlow.map((s, i) => {
const idx = statusFlow.indexOf(status);
const isActive = idx >= i;
const isCurrent = status === s;
return (
<div key={s} className="flex items-center">
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${isCurrent ? 'bg-primary text-white' : isActive ? 'bg-status-resolved-bg text-status-resolved-text' : 'bg-gray-100 text-text-muted'}`}>
{isActive && !isCurrent ? <CheckCircle2 className="w-3.5 h-3.5" /> : <span className="text-[10px] font-bold">{i + 1}</span>}
</div>
<span className={`ml-3 text-xs font-medium ${isCurrent ? 'text-text-main font-semibold' : isActive ? 'text-text-main' : 'text-text-muted'}`}>{statusLabel[s]}</span>
</div>
);
})}
</div>
</div>
{ticket.resolutionNote && (
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-5">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">Resolution Note</h4>
<p className="text-sm text-text-main">{ticket.resolutionNote}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+341
View File
@@ -0,0 +1,341 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { Search, Plus, X } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import TicketCard from '@/components/issues/KanbanBoard';
import { KanbanColumn } from '@/components/issues/KanbanBoard';
import ticketService from '@/services/ticketService';
import type { IssueTicket, TicketCategory, TicketPriority } from '@/types';
import { useCommonCodes } from '@/hooks/useCommonCodes';
const columns = [
{ key: 'OPEN', label: 'Open', color: 'bg-status-open-text' },
{ key: 'ASSIGNED', label: 'Assigned', color: 'bg-status-pending-text' },
{ key: 'IN_PROGRESS', label: 'In Progress', color: 'bg-status-pending-text' },
{ key: 'DONE', label: 'Done', color: 'bg-status-resolved-text' },
];
function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [formData, setFormData] = useState({
title: '',
description: '',
category: 'BUG',
priority: 'NORMAL',
projectId: '',
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSubmitting(true);
try {
await ticketService.createTicket({
title: formData.title,
description: formData.description,
category: formData.category as TicketCategory,
priority: formData.priority as TicketPriority,
projectId: formData.projectId ? parseInt(formData.projectId) : undefined,
});
onSuccess();
onClose();
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to create ticket');
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
<div className="flex justify-between items-center p-6 border-b border-border">
<h3 className="font-outfit text-xl font-semibold text-text-main">New SR Ticket</h3>
<button onClick={onClose} className="p-2 hover:bg-surface-container rounded-lg transition-colors">
<X className="w-5 h-5 text-text-muted" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-error-container/20 border border-error text-error rounded-lg text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Title</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Brief description of the issue"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
placeholder="Detailed explanation, error logs, steps to reproduce..."
rows={4}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Category</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white"
>
<option value="">Select</option>
<option value="BUG">Bug</option>
<option value="SUPPORT">Support</option>
<option value="QA">QA</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Priority</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white"
>
<option value="LOW">Low</option>
<option value="NORMAL">Normal</option>
<option value="HIGH">High</option>
<option value="CRITICAL">Critical</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Project ID</label>
<input
type="number"
value={formData.projectId}
onChange={(e) => setFormData({ ...formData, projectId: e.target.value })}
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Enter project ID"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 text-sm font-medium text-text-main bg-surface-container hover:bg-surface-container-high rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2.5 text-sm font-medium text-white bg-primary hover:opacity-90 rounded-lg transition-opacity disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Ticket'}
</button>
</div>
</form>
</div>
</div>
);
}
export default function IssuesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [tickets, setTickets] = useState<IssueTicket[]>([]);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useState('');
const [filterCategory, setFilterCategory] = useState('ALL');
const [filterPriority, setFilterPriority] = useState('ALL');
const [showCreateModal, setShowCreateModal] = useState(false);
const [mobileColumn, setMobileColumn] = useState<string>(columns[0].key);
const { codes: priorities } = useCommonCodes('TICKET_PRIORITY');
const { codes: categories } = useCommonCodes('TICKET_CATEGORY');
useEffect(() => {
const q = searchParams.get('search');
if (q) setSearchKeyword(q);
}, [searchParams]);
useEffect(() => {
ticketService.getTickets({ size: 100 }).then((res) => {
setTickets(res.data.content);
}).catch(console.error).finally(() => setLoading(false));
}, []);
const refreshTickets = () => {
setLoading(true);
ticketService.getTickets({ size: 100 }).then((res) => {
setTickets(res.data.content);
}).catch(console.error).finally(() => setLoading(false));
};
const filtered = useMemo(() => {
return tickets.filter((t) => {
if (searchKeyword && !t.title.toLowerCase().includes(searchKeyword.toLowerCase())) return false;
if (filterCategory !== 'ALL' && t.category !== filterCategory) return false;
if (filterPriority !== 'ALL' && t.priority !== filterPriority) return false;
return true;
});
}, [tickets, searchKeyword, filterCategory, filterPriority]);
const grouped = useMemo(() => {
const map: Record<string, IssueTicket[]> = {};
columns.forEach((c) => { map[c.key] = []; });
filtered.forEach((t) => {
const colKey = t.status === 'RESOLVED' || t.status === 'CLOSED' ? 'DONE' : t.status;
if (map[colKey]) map[colKey].push(t);
});
return map;
}, [filtered]);
useEffect(() => {
const firstWithTickets = columns.find((col) => grouped[col.key]?.length > 0);
if (firstWithTickets) {
setMobileColumn(firstWithTickets.key);
}
}, [grouped]);
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6">
<div>
<h2 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">SR Issues</h2>
<p className="text-text-muted text-sm mt-0.5">Track and manage field support requests</p>
</div>
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center space-x-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity flex-1 sm:flex-none justify-center"
>
<Plus className="w-4 h-4" />
<span>New Ticket</span>
</button>
<select value={filterPriority} onChange={(e) => setFilterPriority(e.target.value)} className="text-xs bg-white border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary">
<option value="ALL">All Priority</option>
{priorities.map((p) => (
<option key={p.codeValue} value={p.codeValue}>{p.codeName}</option>
))}
</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="text-xs bg-white border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary">
<option value="ALL">All Category</option>
{categories.map((c) => (
<option key={c.codeValue} value={c.codeValue}>{c.codeName}</option>
))}
</select>
</div>
</div>
<div className="relative w-full mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
value={searchKeyword}
onChange={(e) => {
setSearchKeyword(e.target.value);
router.replace(`/issues?search=${encodeURIComponent(e.target.value)}`);
}}
placeholder="Search tickets by title..."
className="w-full sm:w-96 pl-10 pr-4 py-2.5 rounded-xl bg-white border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
/>
</div>
{loading ? (
<div className="flex items-center justify-center h-64 text-text-muted text-sm">Loading tickets...</div>
) : (
<>
{/* Mobile: column selector */}
<div className="md:hidden mb-4">
<select
value={mobileColumn}
onChange={(e) => setMobileColumn(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-white border border-border text-sm"
>
{columns.map((col) => (
<option key={col.key} value={col.key}>
{col.label} ({grouped[col.key]?.length || 0})
</option>
))}
</select>
</div>
{/* Desktop: all columns */}
<div className="hidden md:flex gap-4 overflow-x-auto pb-2">
{columns.map((col) => (
<div key={col.key} className="flex-1 min-w-[220px]">
<KanbanColumn title={col.label} count={grouped[col.key].length} color={col.color}>
{grouped[col.key].map((ticket) => (
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
<TicketCard
ticket={{
ticketId: ticket.ticketId,
title: ticket.title,
projectName: ticket.projectName,
requesterName: ticket.requesterName,
assigneeName: ticket.assigneeName,
priority: ticket.priority,
category: ticket.category,
status: ticket.status,
dueDate: ticket.dueDate,
isImminent: ticket.isImminent,
}}
/>
</Link>
))}
</KanbanColumn>
</div>
))}
</div>
{/* Mobile: single column */}
<div className="md:hidden space-y-3">
{(grouped[mobileColumn] || []).map((ticket) => (
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
<TicketCard
ticket={{
ticketId: ticket.ticketId,
title: ticket.title,
projectName: ticket.projectName,
requesterName: ticket.requesterName,
assigneeName: ticket.assigneeName,
priority: ticket.priority,
category: ticket.category,
status: ticket.status,
dueDate: ticket.dueDate,
isImminent: ticket.isImminent,
}}
/>
</Link>
))}
{(grouped[mobileColumn] || []).length === 0 && (
<div className="flex items-center justify-center h-32 text-text-muted text-sm bg-gray-50/30 rounded-2xl">
No tickets in this column
</div>
)}
</div>
</>
)}
</div>
{showCreateModal && (
<CreateTicketModal onClose={() => setShowCreateModal(false)} onSuccess={refreshTickets} />
)}
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import {
Scan,
CheckCircle2,
Monitor,
AlertTriangle,
Trash2,
Camera,
History,
Info,
ArrowDownLeft,
Settings,
Wrench,
Search,
Loader2
} from 'lucide-react';
import Link from 'next/link';
import AssetVoucherCard from '@/components/itam/AssetVoucherCard';
import equipmentService from '@/services/equipmentService';
import type { Equipment, EquipmentAssignment } from '@/types';
interface ScannedAsset {
equipmentId: number;
model: string;
sn: string;
project: string;
user: string;
status: 'Normal' | 'Broken' | 'Missing';
conditionNote: string;
}
/**
* 자산 반입 (Check-in) 페이지
* 스캔 중심의 일괄 회수 및 상태 점검 프로세스를 제공합니다.
*/
export default function AssetCheckinPage() {
const [isFinished, setIsFinished] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [scannedAssets, setScannedAssets] = useState<ScannedAsset[]>([]);
const [error, setError] = useState<string | null>(null);
// Fetch IN_USE equipment on mount
useEffect(() => {
const fetchInUseEquipment = async () => {
try {
setIsLoading(true);
setError(null);
const response = await equipmentService.getAll({ status: 'IN_USE', size: 100 });
if (response.success && response.data) {
const equipmentList = response.data.content || [];
// Transform Equipment to ScannedAsset format
const transformed: ScannedAsset[] = equipmentList.map((equip: Equipment) => ({
equipmentId: equip.equipmentId,
model: `${equip.manufacturer} ${equip.modelName}`,
sn: equip.serialNumber,
project: '파견 프로젝트', // Will be populated from assignment data if available
user: '미확인', // Will be populated from assignment data if available
status: 'Normal',
conditionNote: 'Normal - 정상 반입'
}));
setScannedAssets(transformed);
}
} catch (err) {
console.error('Failed to fetch IN_USE equipment:', err);
setError('사용 중인 장비 목록을 불러오지 못했습니다.');
} finally {
setIsLoading(false);
}
};
fetchInUseEquipment();
}, []);
const handleStatusChange = useCallback((idx: number, newStatus: 'Normal' | 'Broken' | 'Missing') => {
setScannedAssets(prev => {
const updated = [...prev];
updated[idx] = {
...updated[idx],
status: newStatus,
conditionNote: newStatus === 'Normal' ? 'Normal - 정상 반입'
: newStatus === 'Broken' ? 'Broken - 파손됨, 수리 필요'
: 'Missing - 분실 처리 필요'
};
return updated;
});
}, []);
const handleRmaRequest = useCallback(async (equipmentId: number, condition: string) => {
try {
const today = new Date().toISOString().split('T')[0];
await equipmentService.addMaintenance(equipmentId, {
maintenanceType: 'REPAIR',
issueDescription: condition,
cost: 0,
reportedDate: today
});
alert('RMA 수리 요청이 생성되었습니다.');
} catch (err) {
console.error('Failed to create RMA:', err);
alert('RMA 수리 요청 생성에 실패했습니다.');
}
}, []);
const handleLossReport = useCallback(() => {
alert('변상/손실 보고서 발행 기능이 실행됩니다.\n(관리자 페이지로 이동합니다.)');
}, []);
const handleCheckinComplete = useCallback(async () => {
try {
setIsSubmitting(true);
// Process each asset return
const results = await Promise.allSettled(
scannedAssets.map(asset =>
equipmentService.return_(asset.equipmentId, { conditionNote: asset.conditionNote })
)
);
// Check for failures
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.error('Some returns failed:', failures);
alert(`반입 처리 중 ${failures.length}건 실패했습니다. 콘솔을 확인하세요.`);
} else {
setIsFinished(true);
}
} catch (err) {
console.error('Check-in failed:', err);
alert('반입 처리 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
}
}, [scannedAssets]);
// Loading state
if (isLoading) {
return (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-text-muted font-bold">장비 목록을 불러오는 중...</p>
</div>
</div>
);
}
// Error state
if (error && scannedAssets.length === 0) {
return (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<div className="text-center max-w-md">
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-text-main mb-2">오류 발생</h2>
<p className="text-text-muted mb-6">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-primary text-white rounded-xl font-bold hover:bg-primary-hover transition-all"
>
다시 시도
</button>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
{!isFinished && (
<div className="max-w-7xl mx-auto mb-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 className="text-xl font-bold text-text-main tracking-tight">Asset Check-in</h2>
<p className="text-xs text-text-muted mt-0.5">일괄 반입 및 상태 점검</p>
</div>
<button
onClick={handleCheckinComplete}
disabled={isSubmitting || scannedAssets.length === 0}
className="bg-primary text-white px-6 py-2.5 rounded-xl text-xs font-bold hover:bg-primary-hover transition-all flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<><Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" />처리 중...</>
) : (
<><CheckCircle2 className="w-3.5 h-3.5 mr-1.5" />반입 완료 ({scannedAssets.length}건)</>
)}
</button>
</div>
</div>
)}
<main>
<div className="max-w-7xl mx-auto">
{isFinished ? (
<div className="animate-in zoom-in-95 duration-500">
<div className="text-center mb-10">
<div className="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<ArrowDownLeft className="w-10 h-10 text-blue-600" />
</div>
<h2 className="text-3xl font-bold text-text-main tracking-tight">반입 처리가 완료되었습니다</h2>
<p className="text-text-muted mt-2 text-sm">장비가 창고 재고로 복귀되었으며 상태에 따른 후속 워크플로우가 시작되었습니다.</p>
</div>
<AssetVoucherCard
type="CHECK_IN"
id="VCH-2024-IN-8831"
date={new Date().toISOString().slice(0, 16).replace('T', ' ')}
project="삼성페이 고도화 (SI)"
user="김현장"
assets={scannedAssets.map(a => ({
id: String(a.equipmentId),
model: a.model,
sn: a.sn,
project: a.project,
user: a.user,
status: a.status
}))}
/>
<div className="mt-12 flex justify-center space-x-4">
<Link href="/itam" className="px-10 py-4 bg-text-main text-white rounded-[24px] text-sm font-bold shadow-xl hover:opacity-90 transition-all">
자산 관리 메인으로
</Link>
<Link href="/itam/rma" className="px-10 py-4 bg-surface border border-border text-text-muted rounded-[24px] text-sm font-bold hover:bg-gray-50 transition-all">
RMA 수리 현황 보기
</Link>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Left: Scan Area (4 cols) */}
<div className="lg:col-span-4 space-y-6">
<div className="bg-surface p-8 rounded-[40px] border border-border shadow-card sticky top-28">
<h3 className="text-lg font-bold text-text-main mb-6 flex items-center">
<Scan className="w-5 h-5 mr-2 text-blue-600" />
장비 스캔
</h3>
<div className="aspect-square w-full bg-gray-900 rounded-[32px] relative overflow-hidden flex items-center justify-center group cursor-pointer">
{/* Fake Camera View */}
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80&w=800')] bg-cover bg-center opacity-30 group-hover:opacity-40 transition-opacity"></div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-48 h-48 border-2 border-blue-400/50 rounded-3xl relative">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0.5 bg-blue-400 animate-pulse shadow-[0_0_20px_rgba(59,130,246,0.8)]"></div>
</div>
</div>
<div className="z-10 text-center">
<Camera className="w-10 h-10 text-white mb-2 mx-auto opacity-50" />
<p className="text-xs font-bold text-white uppercase tracking-widest">Scanning...</p>
</div>
</div>
<div className="mt-6 space-y-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input type="text" placeholder="시리얼 번호 수동 입력" className="w-full pl-10 pr-4 py-3 bg-gray-50 border border-border rounded-xl text-xs focus:ring-2 focus:ring-primary/20 outline-none transition-all font-mono" />
</div>
<div className="p-4 bg-blue-50 rounded-2xl border border-blue-100">
<p className="text-[11px] text-blue-700 leading-relaxed font-bold">
<Info className="w-3.5 h-3.5 inline mr-1 -mt-0.5" />
장비를 스캔하면 기존 반출 정보 (프로젝트, 사용자) 가 자동으로 연결됩니다.
</p>
</div>
</div>
</div>
</div>
{/* Right: Asset List & Condition (8 cols) */}
<div className="lg:col-span-8 space-y-6">
<div className="bg-surface p-8 rounded-[40px] border border-border shadow-card min-h-[600px]">
<div className="flex items-center justify-between mb-8">
<h3 className="text-lg font-bold text-text-main flex items-center">
<History className="w-5 h-5 mr-2 text-text-muted" />
반입 대기 목록
<span className="ml-3 px-2.5 py-0.5 bg-gray-100 text-text-muted text-[10px] font-bold rounded-full">
{scannedAssets.length} Assets
</span>
</h3>
</div>
{scannedAssets.length === 0 ? (
<div className="text-center py-20">
<Monitor className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-text-muted font-bold">반입 대기 장비가 없습니다.</p>
<p className="text-text-muted text-sm mt-2">IN_USE 상태의 장비가 없습니다.</p>
</div>
) : (
<div className="space-y-4">
{scannedAssets.map((asset, i) => (
<div key={asset.equipmentId} className="group p-6 bg-gray-50/50 rounded-[32px] border border-border hover:bg-surface hover:border-blue-200 hover:shadow-xl hover:shadow-blue-500/5 transition-all">
<div className="flex items-start justify-between">
<div className="flex items-center">
<div className="w-12 h-12 rounded-2xl bg-surface border border-border flex items-center justify-center mr-4 shadow-card">
<Monitor className={`w-6 h-6 ${asset.status === 'Broken' ? 'text-orange-400' : 'text-text-muted'}`} />
</div>
<div>
<div className="flex items-center space-x-2 mb-0.5">
<p className="text-sm font-bold text-text-main">{asset.model}</p>
<span className="text-[9px] font-bold bg-gray-200 text-text-muted px-1.5 py-0.5 rounded uppercase tracking-tighter">SN: {asset.sn}</span>
</div>
<p className="text-[11px] text-text-muted">
<span className="font-bold text-text-muted">{asset.project}</span> ∙ {asset.user} 님이 사용 중
</p>
</div>
</div>
<button className="p-2 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all">
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Condition Selection */}
<div className="mt-6 pt-6 border-t border-border flex items-center justify-between">
<div className="flex space-x-2">
{['Normal', 'Broken', 'Missing'].map(status => (
<button
key={status}
onClick={() => handleStatusChange(i, status as 'Normal' | 'Broken' | 'Missing')}
className={`px-4 py-2 rounded-xl text-[10px] font-bold border transition-all ${
asset.status === status
? (status === 'Normal' ? 'bg-emerald-500 text-white border-emerald-500' :
status === 'Broken' ? 'bg-orange-500 text-white border-orange-500' :
'bg-red-500 text-white border-red-500')
: 'bg-surface text-text-muted border-border hover:border-gray-300'
}`}
>
{status}
</button>
))}
</div>
{/* Contextual Action Trigger */}
{asset.status === 'Broken' && (
<div
className="flex items-center text-orange-600 animate-in fade-in slide-in-from-right-2 duration-300 cursor-pointer hover:text-orange-700"
onClick={() => handleRmaRequest(asset.equipmentId, asset.conditionNote)}
>
<Wrench className="w-3.5 h-3.5 mr-1.5" />
<span className="text-[10px] font-bold underline">수리 의뢰 (RMA) 자동 생성</span>
</div>
)}
{asset.status === 'Missing' && (
<div
className="flex items-center text-red-600 animate-in fade-in slide-in-from-right-2 duration-300 cursor-pointer hover:text-red-700"
onClick={handleLossReport}
>
<AlertTriangle className="w-3.5 h-3.5 mr-1.5" />
<span className="text-[10px] font-bold underline">변상/손실 보고서 발행</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</main>
</div>
);
}
+551
View File
@@ -0,0 +1,551 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
ChevronRight,
Search,
User,
MapPin,
Scan,
CheckCircle2,
PenTool,
ArrowUpRight,
Monitor,
ShieldCheck,
X,
Loader2
} from 'lucide-react';
import Link from 'next/link';
import AssetVoucherCard from '@/components/itam/AssetVoucherCard';
import equipmentService from '@/services/equipmentService';
import employeeService from '@/services/employeeService';
import type { Equipment, EmployeeSearchResult } from '@/types';
interface SelectedAsset {
equipmentId: number;
model: string;
sn: string;
status: string;
manufacturer: string;
equipmentType: string;
}
/**
* 자산 반출 (Check-out) 마법사 페이지
* 프로젝트별 장비 배정과 책임 소재 증빙 프로세스를 제공합니다.
*/
export default function AssetCheckoutPage() {
const [step, setStep] = useState(1);
const [selectedProject, setSelectedProject] = useState('');
const [selectedUser, setSelectedUser] = useState<EmployeeSearchResult | null>(null);
const [selectedAssets, setSelectedAssets] = useState<SelectedAsset[]>([]);
const [isFinished, setIsFinished] = useState(false);
// Loading & Error states
const [isLoadingAssets, setIsLoadingAssets] = useState(false);
const [isLoadingEmployees, setIsLoadingEmployees] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Data states
const [availableEquipment, setAvailableEquipment] = useState<Equipment[]>([]);
const [searchResults, setSearchResults] = useState<EmployeeSearchResult[]>([]);
const [userSearchQuery, setUserSearchQuery] = useState('');
// Debounce ref for employee search
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fetch available equipment on mount
useEffect(() => {
const fetchAvailableEquipment = async () => {
setIsLoadingAssets(true);
setError(null);
try {
const response = await equipmentService.getAll({ status: 'AVAILABLE', size: 100 });
if (response.success) {
setAvailableEquipment(response.data.content);
}
} catch (err) {
setError('장비 목록을 불러오는데 실패했습니다.');
console.error('Failed to fetch equipment:', err);
} finally {
setIsLoadingAssets(false);
}
};
fetchAvailableEquipment();
}, []);
// Debounced employee search
const handleUserSearch = useCallback(async (query: string) => {
setUserSearchQuery(query);
// Clear existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
// Set new timeout for debounce (300ms)
searchTimeoutRef.current = setTimeout(async () => {
if (query.trim() === '') {
setSearchResults([]);
return;
}
setIsLoadingEmployees(true);
try {
const response = await employeeService.search(query);
if (response.success) {
setSearchResults(response.data);
}
} catch (err) {
console.error('Failed to search employees:', err);
} finally {
setIsLoadingEmployees(false);
}
}, 300);
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, []);
// Handle asset selection toggle
const toggleAssetSelection = (equipment: Equipment) => {
const isSelected = selectedAssets.some(a => a.equipmentId === equipment.equipmentId);
if (isSelected) {
setSelectedAssets(prev => prev.filter(a => a.equipmentId !== equipment.equipmentId));
} else {
setSelectedAssets(prev => [...prev, {
equipmentId: equipment.equipmentId,
model: `${equipment.manufacturer} ${equipment.modelName}`,
sn: equipment.serialNumber,
status: 'Normal',
manufacturer: equipment.manufacturer,
equipmentType: equipment.equipmentType
}]);
}
};
// Handle step 3 submission - assign equipment
const handleAssignEquipment = async () => {
if (!selectedUser || selectedAssets.length === 0) {
setError('사용자와 장비를 선택해 주세요.');
return;
}
setIsSubmitting(true);
setError(null);
try {
// Assign each selected equipment
for (const asset of selectedAssets) {
await equipmentService.assign(asset.equipmentId, selectedUser.empId, '초기 지급');
}
// Success - show completion screen
setIsFinished(true);
} catch (err: any) {
setError(err?.response?.data?.message || '장비 배정에 실패했습니다. 다시 시도해 주세요.');
console.error('Failed to assign equipment:', err);
} finally {
setIsSubmitting(false);
}
};
// Generate voucher ID for completion screen
const generateVoucherId = () => {
const now = new Date();
const year = now.getFullYear();
const random = Math.floor(Math.random() * 9000) + 1000;
return `VCH-${year}-OUT-${random}`;
};
return (
<div className="p-4 sm:p-6 lg:p-8">
{!isFinished && (
<div className="max-w-7xl mx-auto mb-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h2 className="text-xl font-bold text-text-main tracking-tight">Asset Check-out</h2>
<div className="flex items-center gap-2">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold transition-all ${
step === s ? 'bg-orange-500 text-white shadow-lg' :
step > s ? 'bg-emerald-500 text-white' : 'bg-slate-100 text-slate-400'
}`}>
{step > s ? <CheckCircle2 className="w-3.5 h-3.5" /> : s}
</div>
{s < 3 && <div className={`w-8 h-0.5 mx-1 ${step > s ? 'bg-emerald-500' : 'bg-slate-200'}`}></div>}
</div>
))}
</div>
</div>
</div>
)}
<main>
<div className="max-w-7xl mx-auto">
{isFinished ? (
<div className="animate-in zoom-in-95 duration-500">
<div className="text-center mb-10">
<div className="w-20 h-20 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
</div>
<h2 className="text-3xl font-bold text-slate-900 tracking-tight">반출 처리가 완료되었습니다</h2>
<p className="text-slate-500 mt-2 text-sm">해당 장비의 책임 소재가 사용자에게 배정되었으며 디지털 전표가 발행되었습니다.</p>
</div>
<AssetVoucherCard
type="CHECK_OUT"
id={generateVoucherId()}
date={new Date().toISOString().slice(0, 16).replace('T', ' ')}
project={selectedProject || '프로젝트명'}
user={selectedUser?.name || '사용자명'}
assets={selectedAssets.map(a => ({
model: a.model,
sn: a.sn,
status: a.status
}))}
signature={selectedUser?.name || ''}
/>
<div className="mt-12 flex justify-center space-x-4">
<Link href="/itam" className="px-10 py-4 bg-slate-900 text-white rounded-[24px] text-sm font-bold shadow-xl hover:bg-slate-800 transition-all">
자산 관리 메인으로
</Link>
<button onClick={() => window.print()} className="px-10 py-4 bg-white border border-slate-200 text-slate-600 rounded-[24px] text-sm font-bold hover:bg-slate-50 transition-all">
전표 인쇄하기
</button>
</div>
</div>
) : (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Error Banner */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-2xl p-4 flex items-center justify-between">
<p className="text-sm text-red-700 font-medium">{error}</p>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
<X className="w-5 h-5" />
</button>
</div>
)}
{/* Step 1: Project & User */}
{step === 1 && (
<div className="bg-white p-10 rounded-[40px] border border-slate-200 shadow-sm space-y-8">
<div className="space-y-2">
<h3 className="text-2xl font-bold text-slate-900">배정 대상 및 책임자 선정</h3>
<p className="text-sm text-slate-500">장비가 사용될 프로젝트와 최종 책임 사용자를 지정해 주세요.</p>
</div>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest ml-1">대상 프로젝트</label>
<div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full pl-12 pr-4 py-4 bg-slate-50 border border-slate-200 rounded-[20px] text-sm font-bold focus:ring-2 focus:ring-orange-500/20 outline-none transition-all appearance-none"
>
<option value="">프로젝트 선택</option>
<option>삼성페이 고도화 (SI)</option>
<option>카카오 유지보수</option>
<option>본사 R&D 센터</option>
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest ml-1">최종 책임 사용자 (End-User)</label>
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
value={userSearchQuery}
onChange={(e) => handleUserSearch(e.target.value)}
placeholder="사용자 성함 입력"
className="w-full pl-12 pr-4 py-4 bg-slate-50 border border-slate-200 rounded-[20px] text-sm font-bold focus:ring-2 focus:ring-orange-500/20 outline-none transition-all"
/>
{/* Search Results Dropdown */}
{isLoadingEmployees && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl border border-slate-200 shadow-lg p-4 text-center">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin mx-auto" />
<p className="text-xs text-slate-500 mt-2">검색 중...</p>
</div>
)}
{searchResults.length > 0 && !isLoadingEmployees && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-2xl border border-slate-200 shadow-lg overflow-hidden z-50">
{searchResults.map((emp) => (
<button
key={emp.empId}
onClick={() => {
setSelectedUser(emp);
setSearchResults([]);
setUserSearchQuery(emp.name);
}}
className="w-full px-4 py-3 text-left hover:bg-slate-50 border-b border-slate-100 last:border-b-0 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-bold text-slate-900">{emp.name}</p>
<p className="text-xs text-slate-500">{emp.email}</p>
</div>
<div className="flex items-center space-x-2">
<span className="text-[10px] font-bold text-slate-400 px-2 py-1 bg-slate-100 rounded-full">
{emp.roleCode}
</span>
<span className={`text-[10px] font-bold px-2 py-1 rounded-full ${
emp.dispatchStatus === 'DISPATCHED'
? 'bg-amber-100 text-amber-700'
: 'bg-emerald-100 text-emerald-700'
}`}>
{emp.dispatchStatus === 'DISPATCHED' ? '파견중' : '대기'}
</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Selected User Display */}
{selectedUser && (
<div className="mt-3 p-3 bg-orange-50 border border-orange-100 rounded-xl flex items-center justify-between">
<div className="flex items-center">
<div className="w-8 h-8 bg-orange-200 rounded-full flex items-center justify-center mr-3">
<User className="w-4 h-4 text-orange-700" />
</div>
<div>
<p className="text-sm font-bold text-orange-900">{selectedUser.name}</p>
<p className="text-xs text-orange-600">{selectedUser.email}</p>
</div>
</div>
<button
onClick={() => {
setSelectedUser(null);
setUserSearchQuery('');
}}
className="text-orange-400 hover:text-orange-600"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
<div className="pt-8 border-t border-slate-100 flex justify-end">
<button
onClick={() => setStep(2)}
disabled={!selectedProject || !selectedUser}
className="bg-slate-900 text-white px-10 py-4 rounded-[24px] text-sm font-bold shadow-xl shadow-slate-200 hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-all flex items-center"
>
다음 단계: 장비 선택
<ChevronRight className="w-4 h-4 ml-2" />
</button>
</div>
</div>
)}
{/* Step 2: Asset Selection */}
{step === 2 && (
<div className="bg-white p-10 rounded-[40px] border border-slate-200 shadow-sm space-y-8">
<div className="space-y-2 text-center">
<h3 className="text-2xl font-bold text-slate-900 tracking-tight">반출 장비 선택</h3>
<p className="text-sm text-slate-500">사용자에게 배정할 사용 가능한 장비를 선택해 주세요.</p>
</div>
{/* Equipment List */}
<div className="space-y-4">
{isLoadingAssets ? (
<div className="py-12 text-center">
<Loader2 className="w-8 h-8 text-slate-400 animate-spin mx-auto" />
<p className="text-sm text-slate-500 mt-4">장비 목록을 불러오는 중...</p>
</div>
) : availableEquipment.length === 0 ? (
<div className="py-12 text-center bg-slate-50 rounded-2xl">
<Monitor className="w-12 h-12 text-slate-300 mx-auto" />
<p className="text-sm font-bold text-slate-500 mt-4">사용 가능한 장비가 없습니다</p>
<p className="text-xs text-slate-400 mt-1">재고 상태를 확인해 주세요.</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 max-h-96 overflow-y-auto p-2">
{availableEquipment.map((equipment) => {
const isSelected = selectedAssets.some(a => a.equipmentId === equipment.equipmentId);
return (
<button
key={equipment.equipmentId}
onClick={() => toggleAssetSelection(equipment)}
className={`p-4 rounded-2xl border-2 text-left transition-all ${
isSelected
? 'border-orange-500 bg-orange-50 shadow-md'
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm'
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center">
<Monitor className="w-5 h-5 text-slate-400" />
</div>
{isSelected && (
<CheckCircle2 className="w-5 h-5 text-orange-500" />
)}
</div>
<p className="text-sm font-bold text-slate-900 truncate">
{equipment.manufacturer} {equipment.modelName}
</p>
<p className="text-xs font-mono text-slate-500 mt-1 truncate">
{equipment.serialNumber}
</p>
<div className="flex items-center mt-2 space-x-2">
<span className="text-[10px] font-bold text-slate-400 px-2 py-1 bg-slate-100 rounded-full">
{equipment.equipmentType}
</span>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Selected List */}
{selectedAssets.length > 0 && (
<div className="space-y-3">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1">선택된 장비 ({selectedAssets.length})</p>
{selectedAssets.map((asset) => (
<div key={asset.equipmentId} className="flex items-center justify-between p-4 bg-slate-50 rounded-[20px] border border-slate-100">
<div className="flex items-center">
<div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center mr-4 shadow-sm">
<Monitor className="w-5 h-5 text-slate-400" />
</div>
<div>
<p className="text-sm font-bold text-slate-900">{asset.model}</p>
<p className="text-xs font-mono text-slate-400">{asset.sn}</p>
</div>
</div>
<button
onClick={() => toggleAssetSelection({
equipmentId: asset.equipmentId,
manufacturer: asset.manufacturer,
modelName: asset.model.replace(asset.manufacturer + ' ', ''),
serialNumber: asset.sn,
assetCode: '',
equipmentType: asset.equipmentType as any,
specifications: {},
status: 'AVAILABLE' as any,
purchaseDate: '',
warrantyExpDate: '',
version: 0
})}
className="text-[10px] font-bold text-red-400 hover:text-red-600 transition-colors"
>
삭제
</button>
</div>
))}
</div>
)}
<div className="pt-8 border-t border-slate-100 flex justify-between items-center">
<button onClick={() => setStep(1)} className="text-sm font-bold text-slate-400 hover:text-slate-600">이전 단계로</button>
<button
onClick={() => setStep(3)}
disabled={selectedAssets.length === 0}
className="bg-slate-900 text-white px-10 py-4 rounded-[24px] text-sm font-bold shadow-xl shadow-slate-200 hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-all flex items-center"
>
다음 단계: 책임 확인 및 서명
<ChevronRight className="w-4 h-4 ml-2" />
</button>
</div>
</div>
)}
{/* Step 3: Signature */}
{step === 3 && (
<div className="bg-white p-10 rounded-[40px] border border-slate-200 shadow-sm space-y-8">
<div className="space-y-2 text-center">
<h3 className="text-2xl font-bold text-slate-900 tracking-tight">최종 확인 및 서명</h3>
<p className="text-sm text-slate-500">장비 인수 시 파손 및 분실에 대한 책임 소재를 확인하고 서명해 주세요.</p>
</div>
<div className="p-8 bg-orange-50/50 rounded-[32px] border border-orange-100 text-orange-900">
<h4 className="text-sm font-bold mb-3 flex items-center">
<ShieldCheck className="w-4 h-4 mr-2" />
자산 인수 책임 선언문
</h4>
<p className="text-xs leading-relaxed opacity-80 italic">
"본인은 위 명시된 프로젝트 수행을 위해 해당 자산을 정상적인 상태로 인수하였음을 확인합니다. 사용 중 발생하는 본인의 과실에 의한 파손 및 분실에 대해서는 회사의 자산 관리 규정에 따라 변상 또는 복구 책임이 있음을 인지하며, 프로젝트 종료 시 즉시 반납할 것을 약속합니다."
</p>
</div>
{/* Summary of selected items */}
<div className="bg-slate-50 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-slate-200">
<span className="text-xs font-bold text-slate-500 uppercase">프로젝트</span>
<span className="text-sm font-bold text-slate-900">{selectedProject}</span>
</div>
<div className="flex items-center justify-between pb-4 border-b border-slate-200">
<span className="text-xs font-bold text-slate-500 uppercase">책임 사용자</span>
<span className="text-sm font-bold text-slate-900">{selectedUser?.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-500 uppercase">배정 장비</span>
<span className="text-sm font-bold text-slate-900">{selectedAssets.length} 대</span>
</div>
</div>
<div className="max-w-sm mx-auto text-center">
<label className="text-[10px] font-bold text-slate-400 uppercase mb-3 block tracking-widest">Digital Signature Area</label>
<div className="aspect-[2/1] bg-slate-50 rounded-[24px] border-2 border-dashed border-slate-200 flex flex-col items-center justify-center group hover:bg-white hover:border-orange-300 transition-all cursor-pointer">
<PenTool className="w-8 h-8 text-slate-200 group-hover:text-orange-300 transition-colors" />
<p className="text-[10px] font-bold text-slate-300 mt-2">서명 영역 (자동 입력)</p>
<div className="mt-4 px-6 py-2 bg-white border border-slate-200 rounded-xl font-cursive text-xl text-slate-700 shadow-sm">
{selectedUser?.name || ''}
</div>
</div>
</div>
<div className="pt-8 border-t border-slate-100 flex justify-between items-center">
<button onClick={() => setStep(2)} className="text-sm font-bold text-slate-400 hover:text-slate-600">이전 단계로</button>
<button
onClick={handleAssignEquipment}
disabled={isSubmitting || selectedAssets.length === 0}
className="bg-orange-500 text-white px-12 py-4 rounded-[24px] text-sm font-bold shadow-xl shadow-orange-200 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
처리 중...
</>
) : (
<>
<ArrowUpRight className="w-4 h-4 mr-2" />
반출 완료 및 전표 발행
</>
)}
</button>
</div>
</div>
)}
</div>
)}
</div>
</main>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
ArrowUpRight,
ArrowDownLeft,
Wrench,
Plus,
Calendar,
User,
MapPin,
Image as ImageIcon,
Loader2,
AlertCircle
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import equipmentService from '@/services/equipmentService';
import type { Equipment, EquipmentAssignment, EquipmentMaintenance } from '@/types';
interface TimelineItem {
id: number;
type: 'checkout' | 'checkin' | 'maintenance' | 'register';
label: string;
date: string;
description: string;
actor: string;
location: string;
photo: boolean;
}
/**
* 자산 상세 이력 페이지
* 특정 장비의 모든 이동 및 상태 변화 이력을 타임라인으로 표시합니다.
*/
export default function AssetHistoryPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const { id } = React.use(params);
const [equipment, setEquipment] = useState<Equipment | null>(null);
const [timeline, setTimeline] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const equipId = Number(id);
if (!equipId || isNaN(equipId)) {
setInfo('Select an equipment to view its history.');
setLoading(false);
return;
}
if (isNaN(equipId)) {
setError('Invalid equipment ID');
setLoading(false);
return;
}
const [equipRes, assignmentsRes, maintenancesRes] = await Promise.all([
equipmentService.getOne(equipId),
equipmentService.getAssignments(equipId),
equipmentService.getMaintenances(equipId),
]);
if (!equipRes.success || !assignmentsRes.success || !maintenancesRes.success) {
setError('Failed to fetch equipment history');
setLoading(false);
return;
}
setEquipment(equipRes.data);
const items: TimelineItem[] = [];
assignmentsRes.data.forEach((assignment) => {
items.push({
id: assignment.assignmentId,
type: 'checkout',
label: '반출 (현장 배정)',
date: assignment.assignedDate,
description: `${assignment.userName || 'Unknown'} 에게 장비가 지급되었습니다.`,
actor: '시스템',
location: '현장',
photo: false,
});
if (assignment.returnedDate) {
items.push({
id: assignment.assignmentId + 10000,
type: 'checkin',
label: '반납 완료',
date: assignment.returnedDate,
description: `장비가 반납되었습니다. 상태: ${assignment.condOnReturn || '확인됨'}`,
actor: '시스템',
location: '본사 창고',
photo: assignment.condOnReturn ? true : false,
});
}
});
maintenancesRes.data.forEach((maint) => {
const isResolved = !!maint.resolvedDate;
items.push({
id: maint.maintenanceId + 20000,
type: 'maintenance',
label: isResolved ? '정비 완료' : '정비 진행중',
date: isResolved ? maint.resolvedDate! : maint.reportedDate,
description: maint.actionTaken || maint.issueDescription,
actor: maint.reporterName || '시스템',
location: maint.maintenanceType === 'DISPOSAL' ? '폐기 처리' : '서비스 센터',
photo: true,
});
});
if (equipRes.data.purchaseDate) {
items.push({
id: 99999,
type: 'register',
label: '신규 자산 등록',
date: equipRes.data.purchaseDate,
description: `${equipRes.data.manufacturer} ${equipRes.data.modelName} 이 신규 입고되었습니다.`,
actor: '시스템',
location: '본사 창고',
photo: true,
});
}
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setTimeline(items);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}
fetchData();
}, [id]);
const getIcon = (type: string) => {
switch (type) {
case 'checkout':
return <ArrowUpRight className="w-4 h-4 text-orange-500" />;
case 'checkin':
return <ArrowDownLeft className="w-4 h-4 text-blue-500" />;
case 'maintenance':
return <Wrench className="w-4 h-4 text-purple-500" />;
case 'register':
return <Plus className="w-4 h-4 text-emerald-500" />;
default:
return <Calendar className="w-4 h-4 text-slate-500" />;
}
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { bg: string; text: string; label: string }> = {
AVAILABLE: { bg: 'bg-emerald-50', text: 'text-status-resolved-text', label: 'Available' },
IN_USE: { bg: 'bg-blue-50', text: 'text-status-open-text', label: 'In Use' },
REPAIRING: { bg: 'bg-amber-50', text: 'text-status-pending-text', label: 'Repairing' },
RETIRED: { bg: 'bg-gray-50', text: 'text-status-closed-text', label: 'Retired' },
LOST: { bg: 'bg-red-50', text: 'text-error', label: 'Lost' },
};
const s = statusMap[status] || statusMap.AVAILABLE;
return (
<span className={`text-xs font-bold ${s.text} ${s.bg} px-3 py-1 rounded-full border border-border`}>
{s.label}
</span>
);
};
if (info) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-surface border border-border rounded-[24px] p-12 text-center">
<h3 className="text-lg font-bold text-text-main mb-2">{info}</h3>
<Link href="/itam" className="inline-block mt-4 px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors">
자산 관리 메인으로
</Link>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</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="bg-red-50 border border-red-200 rounded-[24px] p-8 text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-bold text-red-700 mb-2">Error Loading History</h3>
<p className="text-red-600 mb-4">{error}</p>
<div className="flex justify-center gap-4">
<button onClick={() => router.refresh()} className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Retry
</button>
<Link href="/itam" className="px-4 py-2 bg-surface border border-border text-text-muted rounded-lg hover:bg-gray-50 transition-colors">
자산 관리 메인으로
</Link>
</div>
</div>
</div>
</div>
);
}
if (timeline.length === 0) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center space-x-4 mb-8">
<div>
<h2 className="text-2xl font-bold text-text-main tracking-tight">Asset History</h2>
<p className="text-sm text-text-muted">
{equipment?.assetCode} | {equipment?.modelName}
</p>
</div>
</div>
<div className="bg-surface border border-border rounded-[24px] p-12 text-center">
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-text-main mb-2">No History Found</h3>
<p className="text-text-muted">This equipment has no recorded history.</p>
<Link
href="/itam"
className="inline-block mt-4 px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
>
자산 관리 메인으로
</Link>
</div>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-text-main tracking-tight">Asset History</h2>
<p className="text-sm text-text-muted">
{equipment?.assetCode} | {equipment?.modelName}
</p>
</div>
{equipment && getStatusBadge(equipment.status)}
</div>
{/* Timeline List */}
<div className="relative space-y-6 before:absolute before:inset-0 before:ml-5 before:-translate-x-px before:-h-6 before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-gray-200 before:to-gray-200">
{timeline.map((item) => (
<div key={item.id} className="relative flex items-start group">
{/* Dot Icon */}
<div
className={`absolute left-0 w-10 h-10 rounded-full border-4 border-canvas flex items-center justify-center z-10 transition-transform group-hover:scale-110 ${
item.type === 'checkout'
? 'bg-orange-50'
: item.type === 'checkin'
? 'bg-blue-50'
: item.type === 'maintenance'
? 'bg-purple-50'
: 'bg-emerald-50'
}`}
>
{getIcon(item.type)}
</div>
{/* Card Content */}
<div className="ml-14 flex-1 bg-surface p-6 rounded-[24px] border border-border shadow-card hover:shadow-float transition-all">
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="text-sm font-bold text-text-main">{item.label}</h4>
<div className="flex items-center text-[11px] text-text-muted mt-1 font-medium">
<Calendar className="w-3 h-3 mr-1" />
{item.date}
<span className="mx-2 text-gray-200">|</span>
<User className="w-3 h-3 mr-1" />
{item.actor}
</div>
</div>
<div className="flex items-center text-[10px] font-bold text-text-muted bg-gray-50 px-2 py-0.5 rounded border border-border uppercase">
<MapPin className="w-2.5 h-2.5 mr-1" />
{item.location}
</div>
</div>
<p className="text-sm text-text-main leading-relaxed mb-4">{item.description}</p>
{/* Photo Evidence */}
{item.photo && (
<div className="flex items-center space-x-2">
<div className="w-16 h-16 rounded-xl bg-gray-100 border border-border flex items-center justify-center text-gray-300 relative group overflow-hidden cursor-pointer">
<ImageIcon className="w-5 h-5" />
<div className="absolute inset-0 bg-black/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<p className="text-[10px] font-bold text-text-muted">장비 상태 사진 첨부됨</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Plus, ArrowUpRight, ArrowDownLeft, History, Search, Layers, Download, Filter, FileText, Loader2 } from 'lucide-react';
import Link from 'next/link';
import AssetStatusOverview from '@/components/itam/AssetStatusOverview';
import AssetInventory from '@/components/itam/AssetInventory';
import equipmentService from '@/services/equipmentService';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import type { Equipment } from '@/types';
export default function ITAMDashboard() {
const [equipment, setEquipment] = useState<Equipment[]>([]);
const [summary, setSummary] = useState<Record<string, number>>({ ALL: 0 });
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('ALL');
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const PAGE_SIZE = 10;
const { codes: equipmentStatusCodes, loading: codesLoading } = useCommonCodes('EQUIPMENT_STATUS');
const refresh = (p = 0) => {
setLoading(true);
setPage(p);
Promise.all([
equipmentService.getAll({ page: p, size: PAGE_SIZE }),
equipmentService.getSummary(),
]).then(([eqRes, sumRes]) => {
setEquipment(eqRes.data.content);
setTotalPages(eqRes.data.totalPages);
const counts = sumRes.data || {};
counts.ALL = Object.values(counts).reduce((a: number, b: any) => a + (typeof b === 'number' ? b : 0), 0);
setSummary(counts);
}).catch(console.error).finally(() => setLoading(false));
};
useEffect(() => { refresh(); }, []);
const filtered = search || filter !== 'ALL'
? equipment.filter((e) => {
if (filter !== 'ALL' && e.status !== filter) return false;
if (search && !e.modelName.toLowerCase().includes(search.toLowerCase()) && !e.assetCode.toLowerCase().includes(search.toLowerCase())) return false;
return true;
})
: equipment;
const [generatingPdf, setGeneratingPdf] = useState(false);
const pdfRef = useRef<HTMLDivElement>(null);
const allEquipmentRef = useRef<Equipment[]>([]);
const handleExportPdf = async () => {
setGeneratingPdf(true);
try {
if (allEquipmentRef.current.length === 0) {
const res = await equipmentService.getAll({ size: 1000 });
allEquipmentRef.current = res.data.content;
}
const items = allEquipmentRef.current;
const today = new Date().toISOString().split('T')[0];
// Build HTML content
const statusRow = (s: string) =>
`<span style="display:inline-block;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600;${
s === 'IN_USE' ? 'background:#dbeafe;color:#1d4ed8' :
s === 'AVAILABLE' ? 'background:#dcfce7;color:#15803d' :
s === 'REPAIRING' ? 'background:#fef3c7;color:#b45309' :
s === 'RETIRED' ? 'background:#f3f4f6;color:#6b7280' :
'background:#fce7f3;color:#be185d'
}">${s.replace('_', ' ')}</span>`;
const html = `
<div id="pdf-content" style="padding:40px;font-family:Inter,-apple-system,sans-serif;color:#1e293b;">
<div style="text-align:center;margin-bottom:24px;border-bottom:2px solid #1e293b;padding-bottom:16px;">
<h1 style="font-size:22px;font-weight:700;margin:0;letter-spacing:-0.5px;">IT 자산 관리 대장</h1>
<p style="font-size:12px;color:#64748b;margin:4px 0 0;">SR System HQ Helpdesk · 출력일: ${today}</p>
</div>
<table style="width:100%;border-collapse:collapse;font-size:11px;">
<thead>
<tr style="background:#1e293b;color:#fff;">
<th style="padding:8px 10px;text-align:left;font-weight:600;">자산코드</th>
<th style="padding:8px 10px;text-align:left;font-weight:600;">구분</th>
<th style="padding:8px 10px;text-align:left;font-weight:600;">제조사</th>
<th style="padding:8px 10px;text-align:left;font-weight:600;">모델명</th>
<th style="padding:8px 10px;text-align:left;font-weight:600;">시리얼번호</th>
<th style="padding:8px 10px;text-align:left;font-weight:600;">상태</th>
<th style="padding:8px 10px;text-align:left;font-weight:600;">구매일</th>
<th style="padding:8px 10px;text-align:right;font-weight:600;">보증만료</th>
</tr>
</thead>
<tbody>
${items.map((e, i) => `
<tr style="${i % 2 === 0 ? 'background:#f8fafc' : ''}">
<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-family:monospace;font-size:10px;">${e.assetCode}</td>
<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">${e.equipmentType}</td>
<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">${e.manufacturer}</td>
<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-weight:500;">${e.modelName}</td>
<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-family:monospace;font-size:10px;">${e.serialNumber}</td>
<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">${statusRow(e.status)}</td>
<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;">${e.purchaseDate || '-'}</td>
<td style="padding:6px 10px;border-bottom:1px solid #e2e8f0;text-align:right;">${e.warrantyExpDate || '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e2e8f0;font-size:10px;color:#94a3b8;display:flex;justify-content:space-between;">
<span>총 ${items.length}건</span>
<span>SR System v1.0</span>
</div>
</div>
`;
// Create temp element and generate PDF
const element = document.createElement('div');
element.innerHTML = html;
document.body.appendChild(element);
const html2pdf = (await import('html2pdf.js')).default;
await html2pdf().set({
margin: [10, 10],
filename: `IT_Asset_Ledger_${today}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'landscape' },
}).from(element).save();
document.body.removeChild(element);
} catch (err) {
console.error('PDF export failed:', err);
alert('PDF export failed. Please try again.');
}
setGeneratingPdf(false);
};
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6">
<div>
<h1 className="text-xl font-bold text-text-main tracking-tight flex items-center">
<Layers className="w-5 h-5 mr-3 text-primary" /> IT Asset Management
</h1>
<p className="text-sm text-text-muted mt-0.5">Total {summary.ALL || 0} assets · {summary.IN_USE || 0} in use · {summary.AVAILABLE || 0} available</p>
</div>
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-none">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search assets..." className="pl-9 pr-3 py-2 rounded-xl bg-gray-100 border-none text-sm focus:outline-none focus:ring-2 focus:ring-primary w-full sm:w-48" />
</div>
<button onClick={handleExportPdf} disabled={generatingPdf} className="bg-surface text-text-main border border-border px-4 py-2 rounded-xl text-xs font-semibold hover:bg-gray-50 transition-all flex items-center disabled:opacity-50">
{generatingPdf ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> : <FileText className="w-3.5 h-3.5 mr-1.5" />}
{generatingPdf ? 'Generating...' : 'Export PDF'}
</button>
<Link href="/itam/register" className="bg-primary text-white px-4 py-2 rounded-xl text-xs font-semibold hover:bg-primary-hover transition-all flex items-center">
<Plus className="w-3.5 h-3.5 mr-1.5" /> Register
</Link>
</div>
</div>
<AssetStatusOverview summary={summary} filter={filter} onFilterChange={setFilter} />
<div className="flex flex-wrap items-center gap-2 mt-6 mb-4">
<Filter className="w-4 h-4 text-text-muted" />
<button key="ALL" onClick={() => setFilter('ALL')} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${filter === 'ALL' ? 'bg-primary text-white' : 'bg-surface text-text-muted hover:bg-gray-100 border border-border'}`}>
All
</button>
{codesLoading ? (
<span className="text-xs text-text-muted">Loading...</span>
) : (
equipmentStatusCodes.map((code) => (
<button key={code.codeValue} onClick={() => setFilter(code.codeValue)} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${filter === code.codeValue ? 'bg-primary text-white' : 'bg-surface text-text-muted hover:bg-gray-100 border border-border'}`}>
{code.codeName}
</button>
))
)}
</div>
{loading ? (
<div className="flex items-center justify-center h-48 text-text-muted text-sm">Loading assets...</div>
) : (
<AssetInventory equipment={filtered} onRefresh={() => refresh(0)} currentPage={page} totalPages={totalPages} onPageChange={(p) => refresh(p)} />
)}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import React, { useState } from 'react';
import {
Layers,
FileStack,
Zap,
ShieldCheck,
ChevronRight,
Info
} from 'lucide-react';
import Link from 'next/link';
import SingleRegistrationForm from '@/components/itam/SingleRegistrationForm';
import BatchRegistrationView from '@/components/itam/BatchRegistrationView';
/**
* 자산 등록 메인 페이지 (Registration Controller)
* 단건 등록과 대량 등록 모드를 전환하며 관리합니다.
*/
export default function AssetRegistrationPage() {
const [mode, setMode] = useState<'single' | 'batch'>('single');
const [registeredCount, setRegisteredCount] = useState(0);
return (
<div className="p-4 sm:p-6 lg:p-8">
{/* Top Navigation & Mode Switcher */}
<div className="max-w-7xl mx-auto mb-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-2 sm:gap-4">
<div>
<h2 className="text-xl font-bold text-text-main tracking-tight">New Asset Registration</h2>
<p className="text-xs text-text-muted mt-0.5">
<span className={mode === 'single' ? 'text-primary font-semibold' : ''}>Single Mode</span>
<ChevronRight className="w-3 h-3 mx-1 inline" />
<span className={mode === 'batch' ? 'text-primary font-semibold' : ''}>Batch Mode</span>
</p>
</div>
</div>
{/* Mode Switcher */}
<div className="flex gap-1 bg-slate-100 p-1 rounded-2xl border border-slate-200">
<button onClick={() => setMode('single')}
className={`px-3 sm:px-4 py-2 rounded-xl text-[10px] sm:text-xs font-bold transition-all whitespace-nowrap ${
mode === 'single' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<Zap className="w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-1 sm:mr-2" />
<span className="hidden sm:inline">단건/연속 등록</span>
<span className="sm:hidden">단건</span>
</button>
<button onClick={() => setMode('batch')}
className={`px-3 sm:px-4 py-2 rounded-xl text-[10px] sm:text-xs font-bold transition-all whitespace-nowrap ${
mode === 'batch' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
<FileStack className="w-3 h-3 sm:w-3.5 sm:h-3.5 inline mr-1 sm:mr-2" />
<span className="hidden sm:inline">대량(엑셀) 등록</span>
<span className="sm:hidden">대량</span>
</button>
</div>
<div className="hidden md:flex items-center gap-3">
<div className="text-right">
<p className="text-[10px] font-bold text-text-muted uppercase">Registered Today</p>
<p className="text-lg font-outfit font-bold text-text-main leading-none">{registeredCount} <span className="text-xs text-text-muted">Assets</span></p>
</div>
<ShieldCheck className="w-5 h-5 text-emerald-500" />
</div>
</div>
</div>
{/* Main Content Area */}
<div className="max-w-7xl mx-auto">
{/* Mode Instructions (Contextual) */}
<div className="mb-8 flex items-start p-4 bg-indigo-50/50 rounded-2xl border border-indigo-100/50">
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center mr-3 flex-shrink-0">
<Info className="w-4 h-4 text-indigo-600" />
</div>
<div>
<h4 className="text-sm font-bold text-indigo-900">
{mode === 'single' ? '연속 등록 모드가 활성화되어 있습니다.' : '대량 등록 모드입니다.'}
</h4>
<p className="text-xs text-indigo-700 mt-1 leading-relaxed">
{mode === 'single'
? '장비를 스캔하거나 정보를 입력한 후 [저장 후 계속]을 누르면 화면이 초기화되지 않고 즉시 다음 장비를 등록할 수 있습니다.'
: '엑셀 템플릿 양식에 맞춰 데이터를 작성한 후 업로드해 주세요. 시스템이 자동으로 유효성을 검사합니다.'}
</p>
</div>
</div>
{/* Dynamic Form Content */}
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
{mode === 'single' ? (
<SingleRegistrationForm onRegisterSuccess={() => setRegisteredCount(prev => prev + 1)} />
) : (
<BatchRegistrationView />
)}
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import MainShell from "@/components/layout/MainShell";
export default function MainLayout({ children }: { children: React.ReactNode }) {
return <MainShell>{children}</MainShell>;
}
+289
View File
@@ -0,0 +1,289 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
FileText,
Users,
Monitor,
AlertCircle,
Plus,
ArrowRight,
TrendingUp,
Send,
CheckCircle2,
Calendar,
} from 'lucide-react';
import Link from 'next/link';
import ticketService from '@/services/ticketService';
import equipmentService from '@/services/equipmentService';
import reportService from '@/services/reportService';
import { useAuthStore } from '@/stores';
interface StatCard {
title: string;
icon: React.ComponentType<{ className?: string }>;
iconColor: string;
iconBg: string;
borderColor: string;
}
export default function DashboardPage() {
const user = useAuthStore((s) => s.user);
const [openCount, setOpenCount] = useState<number | null>(null);
const [dispatchCount, setDispatchCount] = useState<number | null>(null);
const [inUseCount, setInUseCount] = useState<number | null>(null);
const [pendingReports, setPendingReports] = useState<number | null>(null);
const [recentTickets, setRecentTickets] = useState<any[]>([]);
const [dailyReport, setDailyReport] = useState({ completedTasks: '', plannedTasks: '', issuesAndRisks: '' });
const [reportSubmitted, setReportSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
Promise.all([
ticketService.getTickets({ status: 'OPEN', size: 1 }).catch(() => null),
ticketService.getTickets({ size: 5 }).catch(() => null),
equipmentService.getSummary().catch(() => null),
]).then(([openRes, recentRes, equipRes]) => {
if (openRes) setOpenCount(openRes.data.totalElements);
if (recentRes) setRecentTickets(recentRes.data.content || []);
if (equipRes?.data) setInUseCount(equipRes.data.IN_USE || 0);
});
}, []);
// Check if daily report already submitted today
useEffect(() => {
if (!user) return;
const today = new Date().toISOString().split('T')[0];
reportService.getAll({
reportType: 'DAILY',
size: 50,
}).then((res) => {
const submitted = (res.data.content || []).some((r: any) => r.targetDate === today);
setReportSubmitted(submitted);
}).catch(() => {});
}, [user]);
useEffect(() => {
if (!user || user.roleCode === 'EMPLOYEE') return;
import('@/services/reportService').then((m) =>
m.default.getAll({ status: 'SUBMITTED', size: 1 }).then((res) => {
setPendingReports(res.data.totalElements);
}).catch(() => {})
);
import('@/services/employeeService').then((m) =>
m.default.getAll({ size: 1 }).then((res) => {
setDispatchCount(res.data.content.filter((e: any) => e.dispatchStatus === 'DISPATCHED').length);
}).catch(() => {})
);
}, [user]);
const stats: (StatCard & { value: string; sub?: string })[] = [
{
title: 'Open SR Tickets',
value: openCount !== null ? String(openCount) : '...',
icon: AlertCircle, iconColor: 'text-status-open-text', iconBg: 'bg-status-open-bg',
borderColor: 'border-l-status-open-text',
},
{
title: 'Active Dispatches',
value: dispatchCount !== null ? String(dispatchCount) : '...',
sub: user?.roleCode === 'EMPLOYEE' ? '' : 'engineers',
icon: Users, iconColor: 'text-primary', iconBg: 'bg-indigo-50',
borderColor: 'border-l-primary',
},
{
title: 'IT Assets In-Use',
value: inUseCount !== null ? String(inUseCount) : '...',
icon: Monitor, iconColor: 'text-dot-orange', iconBg: 'bg-orange-50',
borderColor: 'border-l-dot-orange',
},
{
title: 'Pending Reports',
value: pendingReports !== null ? String(pendingReports) : '...',
icon: FileText, iconColor: 'text-dot-purple', iconBg: 'bg-purple-50',
borderColor: 'border-l-dot-purple',
},
];
const statusBadge = (status: string) => {
const map: Record<string, { label: string; cls: string }> = {
OPEN: { label: 'Open', cls: 'bg-status-open-bg text-status-open-text' },
ASSIGNED: { label: 'Assigned', cls: 'bg-blue-50 text-blue-600' },
IN_PROGRESS: { label: 'In-Progress', cls: 'bg-status-pending-bg text-status-pending-text' },
RESOLVED: { label: 'Resolved', cls: 'bg-status-resolved-bg text-status-resolved-text' },
CLOSED: { label: 'Closed', cls: 'bg-status-closed-bg text-status-closed-text' },
};
const m = map[status] || { label: status, cls: 'bg-gray-100 text-gray-600' };
return <span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${m.cls}`}>{m.label}</span>;
};
const handleDailyReport = async () => {
if (!dailyReport.completedTasks.trim()) return;
setSubmitting(true);
try {
await reportService.create({
reportType: 'DAILY',
targetDate: new Date().toISOString().split('T')[0],
completedTasks: dailyReport.completedTasks,
plannedTasks: dailyReport.plannedTasks,
issuesAndRisks: dailyReport.issuesAndRisks,
supportRequested: '',
dispatchId: 0,
status: 'SUBMITTED',
});
setReportSubmitted(true);
setDailyReport({ completedTasks: '', plannedTasks: '', issuesAndRisks: '' });
} catch {
alert('Failed to submit daily report.');
}
setSubmitting(false);
};
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Dashboard Overview</h2>
<p className="text-text-muted mt-1 text-sm">Welcome back{user ? `, ${user.name}` : ''}. Here is the latest status.</p>
</div>
<Link
href="/issues"
className="w-full sm:w-auto bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold shadow-soft transition-all flex items-center justify-center"
>
<Plus className="w-4 h-4 mr-2" />
New SR Ticket
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 mb-6 sm:mb-8">
{stats.map((stat, i) => {
const Icon = stat.icon;
const isPendingReports = stat.title === 'Pending Reports';
const card = (
<div key={i} className={`bg-surface rounded-2xl p-6 shadow-card border border-border border-l-4 ${stat.borderColor} flex flex-col justify-between hover:shadow-soft transition-shadow ${isPendingReports ? 'cursor-pointer' : ''}`}>
<div className="flex justify-between items-start mb-4">
<h3 className="text-text-muted text-sm font-medium">{stat.title}</h3>
<div className={`w-8 h-8 rounded-full ${stat.iconBg} flex items-center justify-center ${stat.iconColor}`}>
<Icon className="w-4 h-4" />
</div>
</div>
<div className="flex items-end justify-between">
<span className="font-outfit text-3xl font-semibold text-text-main">{stat.value}</span>
{stat.sub && <span className="text-xs text-text-muted">{stat.sub}</span>}
</div>
</div>
);
return isPendingReports
? <Link key={i} href="/work-reports/review">{card}</Link>
: card;
})}
</div>
{/* Daily Report Widget */}
<div className="bg-surface rounded-2xl shadow-card border border-border p-4 sm:p-6 mb-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-primary" />
<h3 className="font-outfit font-semibold text-lg text-text-main">Today's Daily Report</h3>
{reportSubmitted && (
<span className="ml-auto flex items-center gap-1 text-xs font-medium text-status-resolved-text bg-status-resolved-bg px-3 py-1 rounded-full">
<CheckCircle2 className="w-3.5 h-3.5" />
Submitted
</span>
)}
</div>
{reportSubmitted ? (
<div className="bg-status-resolved-bg/50 rounded-xl px-4 py-5 text-center">
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-status-resolved-text" />
<p className="text-sm font-medium text-text-main">Today's report has been submitted.</p>
<p className="text-xs text-text-muted mt-1">
{new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}
</p>
</div>
) : (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-text-muted mb-1 block">What did you do today?</label>
<textarea
value={dailyReport.completedTasks}
onChange={(e) => setDailyReport(r => ({ ...r, completedTasks: e.target.value }))}
placeholder="Completed tasks, fixes, meetings..."
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={2}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-text-muted mb-1 block">Plans for tomorrow</label>
<textarea
value={dailyReport.plannedTasks}
onChange={(e) => setDailyReport(r => ({ ...r, plannedTasks: e.target.value }))}
placeholder="Next steps..."
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={2}
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted mb-1 block">Issues / Risks</label>
<textarea
value={dailyReport.issuesAndRisks}
onChange={(e) => setDailyReport(r => ({ ...r, issuesAndRisks: e.target.value }))}
placeholder="Blockers, risks, or help needed..."
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={2}
/>
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleDailyReport}
disabled={submitting || !dailyReport.completedTasks.trim()}
className="bg-primary hover:bg-primary-hover text-white px-5 py-2 rounded-xl text-sm font-semibold transition-all flex items-center gap-2 disabled:opacity-50"
>
<Send className="w-4 h-4" />
{submitting ? 'Submitting...' : 'Submit Daily Report'}
</button>
</div>
</div>
)}
</div>
<div className="bg-surface rounded-2xl shadow-card border border-border overflow-hidden">
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-border flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<h3 className="font-outfit font-semibold text-lg text-text-main">Recent SR Tickets</h3>
<Link href="/issues" className="text-sm font-medium text-primary hover:text-primary-hover flex items-center">
View all <ArrowRight className="w-4 h-4 ml-1" />
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50/50">
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">ID</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Project</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Issue Title</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Requester</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Status</th>
</tr>
</thead>
<tbody className="text-sm divide-y divide-gray-100">
{recentTickets.length === 0 && (
<tr><td colSpan={5} className="py-8 text-center text-text-muted text-sm">No tickets found.</td></tr>
)}
{recentTickets.map((t: any) => (
<tr key={t.ticketId} className="hover:bg-gray-50 transition-colors cursor-pointer" onClick={() => window.location.href = `/issues/${t.ticketId}`}>
<td className="py-4 px-4 sm:px-6 font-medium text-text-main">#{t.ticketId}</td>
<td className="py-4 px-4 sm:px-6 text-text-muted">{t.projectName}</td>
<td className="py-4 px-4 sm:px-6 font-medium text-text-main">{t.title}</td>
<td className="py-4 px-4 sm:px-6 text-text-muted">{t.requesterName}</td>
<td className="py-4 px-4 sm:px-6">{statusBadge(t.status)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
+360
View File
@@ -0,0 +1,360 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { UserCircle, Mail, Briefcase, Calendar, Lock, Shield, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import employeeService from '@/services/employeeService';
import type { ApiResponse, Employee, DispatchHistory } from '@/types';
interface EmployeeWithId extends Employee {
empId: number;
}
export default function ProfilePage() {
const router = useRouter();
const [employee, setEmployee] = useState<EmployeeWithId | null>(null);
const [dispatchHistories, setDispatchHistories] = useState<DispatchHistory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Password change form state
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
const [isChangingPassword, setIsChangingPassword] = useState(false);
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
setLoading(true);
const empRes = await employeeService.getMe();
if (empRes.success && empRes.data) {
const empData = empRes.data as EmployeeWithId;
setEmployee(empData);
// Load dispatch histories
const historyRes = await employeeService.getDispatchHistories(empData.empId);
if (historyRes.success) {
const data = historyRes.data;
setDispatchHistories(Array.isArray(data) ? data : []);
}
}
} catch (err) {
setError('프로필 정보를 불러오는데 실패했습니다.');
console.error(err);
} finally {
setLoading(false);
}
};
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError(null);
setPasswordSuccess(null);
if (newPassword !== confirmPassword) {
setPasswordError('새 비밀번호가 일치하지 않습니다.');
return;
}
if (newPassword.length < 4) {
setPasswordError('새 비밀번호는 최소 4 자 이상이어야 합니다.');
return;
}
try {
setIsChangingPassword(true);
const res = await employeeService.changePassword(currentPassword, newPassword);
if (res.success) {
setPasswordSuccess('비밀번호가 성공적으로 변경되었습니다.');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
}
} catch (err: any) {
setPasswordError(err.response?.data?.message || '비밀번호 변경에 실패했습니다.');
} finally {
setIsChangingPassword(false);
}
};
const getRoleBadgeColor = (roleCode: string) => {
switch (roleCode) {
case 'ADMIN':
return 'bg-red-100 text-red-700 border-red-200';
case 'MANAGER':
return 'bg-indigo-100 text-indigo-700 border-indigo-200';
case 'EMPLOYEE':
return 'bg-gray-100 text-gray-700 border-gray-200';
default:
return 'bg-gray-100 text-gray-700 border-gray-200';
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'ACTIVE':
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
case 'ON_LEAVE':
return 'bg-amber-100 text-amber-700 border-amber-200';
case 'RESIGNED':
return 'bg-gray-100 text-gray-700 border-gray-200';
default:
return 'bg-gray-100 text-gray-700 border-gray-200';
}
};
const getDispatchStatusBadgeColor = (status: string) => {
switch (status) {
case 'DISPATCHED':
return 'bg-blue-100 text-blue-700 border-blue-200';
case 'WAITING':
return 'bg-amber-100 text-amber-700 border-amber-200';
default:
return 'bg-gray-100 text-gray-700 border-gray-200';
}
};
const formatInitial = (name: string) => name.charAt(0).toUpperCase();
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-center">
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-text-muted">로딩중...</p>
</div>
</div>
);
}
if (error || !employee) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-error mx-auto mb-4" />
<p className="text-text-main font-medium">{error || '프로필 정보를 찾을 수 없습니다.'}</p>
<button
onClick={() => router.push('/')}
className="mt-4 px-6 py-2 bg-primary text-white rounded-xl font-bold text-sm hover:bg-primary/90 transition-colors"
>
대시보드로 이동
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<div>
<h1 className="font-outfit text-2xl font-semibold text-text-main">내 프로필</h1>
<p className="text-text-muted text-sm mt-1">개인 정보 및 파견 이력을 관리합니다.</p>
</div>
{/* Profile Card */}
<div className="bg-surface border border-border rounded-2xl p-6 shadow-soft">
<div className="flex items-start gap-6">
{/* Avatar */}
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-primary to-purple-400 flex items-center justify-center text-white text-3xl font-bold shadow-lg shadow-primary/20 flex-shrink-0">
{formatInitial(employee.name)}
</div>
{/* Info Grid */}
<div className="flex-1 grid grid-cols-2 gap-6">
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<UserCircle className="w-4 h-4" />
<span>이름</span>
</div>
<p className="text-text-main font-semibold text-lg">{employee.name}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Mail className="w-4 h-4" />
<span>이메일</span>
</div>
<p className="text-text-main font-medium">{employee.email}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Shield className="w-4 h-4" />
<span>역할</span>
</div>
<span className={`inline-block px-3 py-1 rounded-full text-xs font-bold border ${getRoleBadgeColor(employee.roleCode)}`}>
{employee.roleCode}
</span>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Briefcase className="w-4 h-4" />
<span>재직 상태</span>
</div>
<span className={`inline-block px-3 py-1 rounded-full text-xs font-bold border ${getStatusBadgeColor(employee.empStatus)}`}>
{employee.empStatus === 'ACTIVE' ? '재직' : employee.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'}
</span>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Briefcase className="w-4 h-4" />
<span>파견 상태</span>
</div>
<span className={`inline-block px-3 py-1 rounded-full text-xs font-bold border ${getDispatchStatusBadgeColor(employee.dispatchStatus)}`}>
{employee.dispatchStatus === 'DISPATCHED' ? '파견중' : '대기'}
</span>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Calendar className="w-4 h-4" />
<span>입사일</span>
</div>
<p className="text-text-main font-medium">{employee.joinedDate}</p>
</div>
</div>
</div>
</div>
{/* Dispatch History */}
<div className="bg-surface border border-border rounded-2xl p-6 shadow-soft">
<div className="flex items-center justify-between mb-6">
<h2 className="font-outfit text-lg font-semibold text-text-main">파견 이력</h2>
<button
onClick={() => router.push(`/profile/portfolio`)}
className="text-primary text-sm font-bold hover:underline flex items-center gap-1"
>
포트폴리오 보기
<Briefcase className="w-4 h-4" />
</button>
</div>
{dispatchHistories.length === 0 ? (
<div className="text-center py-12">
<Briefcase className="w-12 h-12 text-text-muted/30 mx-auto mb-4" />
<p className="text-text-muted">등록된 파견 이력이 없습니다.</p>
</div>
) : (
<div className="space-y-4">
{dispatchHistories.map((history, index) => (
<div
key={history.dispatchId}
className="border border-border rounded-xl p-4 hover:bg-surface-container-low transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="w-2 h-2 rounded-full bg-primary"></span>
<h3 className="font-outfit font-semibold text-text-main">{history.projectName || '프로젝트'}</h3>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-text-muted">역할:</span>
<span className="text-text-main ml-2 font-medium">{history.roleTitle}</span>
</div>
<div>
<span className="text-text-muted">기간:</span>
<span className="text-text-main ml-2 font-medium">
{history.startDate} ~ {history.endDate || '재직중'}
</span>
</div>
</div>
</div>
<div className="text-right">
<span className="text-xs text-text-muted">기여도</span>
<p className="text-primary font-bold text-lg">{history.contributionRate}%</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Password Change Form */}
<div className="bg-surface border border-border rounded-2xl p-6 shadow-soft">
<div className="flex items-center gap-3 mb-6">
<Lock className="w-5 h-5 text-primary" />
<h2 className="font-outfit text-lg font-semibold text-text-main">비밀번호 변경</h2>
</div>
<form onSubmit={handlePasswordChange} className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium text-text-muted mb-2">
현재 비밀번호
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-4 py-3 border border-border rounded-xl bg-surface-container-lowest text-text-main placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
placeholder="현재 비밀번호를 입력하세요"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-muted mb-2">
새 비밀번호
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-4 py-3 border border-border rounded-xl bg-surface-container-lowest text-text-main placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
placeholder="새 비밀번호를 입력하세요"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-muted mb-2">
새 비밀번호 확인
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 border border-border rounded-xl bg-surface-container-lowest text-text-main placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
placeholder="새 비밀번호를 다시 입력하세요"
required
/>
</div>
{passwordError && (
<div className="flex items-center gap-2 text-error text-sm bg-error-container/30 px-4 py-3 rounded-xl">
<XCircle className="w-4 h-4" />
<span>{passwordError}</span>
</div>
)}
{passwordSuccess && (
<div className="flex items-center gap-2 text-emerald-600 text-sm bg-emerald-50 px-4 py-3 rounded-xl">
<CheckCircle className="w-4 h-4" />
<span>{passwordSuccess}</span>
</div>
)}
<button
type="submit"
disabled={isChangingPassword}
className="w-full py-3 bg-primary text-white rounded-xl font-bold text-sm hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-primary/20"
>
{isChangingPassword ? '변경중...' : '비밀번호 변경'}
</button>
</form>
</div>
</div>
</div>
);
}
+311
View File
@@ -0,0 +1,311 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Search, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores';
import projectService from '@/services/projectService';
import ProjectFormModal from '@/components/projects/ProjectFormModal';
import type { Project, ProjectStatus } from '@/types';
const statusColors: Record<ProjectStatus, string> = {
PREPARING: 'bg-blue-50 text-blue-600',
ONGOING: 'bg-amber-50 text-amber-600',
COMPLETED: 'bg-green-50 text-green-600',
HOLD: 'bg-gray-100 text-gray-500',
};
const statusLabels: Record<ProjectStatus, string> = {
PREPARING: 'Preparing',
ONGOING: 'Ongoing',
COMPLETED: 'Completed',
HOLD: 'Hold',
};
const statusFilterOptions: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'PREPARING', label: 'Preparing' },
{ value: 'ONGOING', label: 'Ongoing' },
{ value: 'COMPLETED', label: 'Completed' },
{ value: 'HOLD', label: 'Hold' },
];
export default function ProjectsPage() {
const user = useAuthStore((s) => s.user);
const role = user?.roleCode;
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('ALL');
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editData, setEditData] = useState<Project | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const fetchProjects = () => {
setLoading(true);
const statusParam = filter !== 'ALL' ? filter : undefined;
projectService.getAll(statusParam)
.then((res) => {
setProjects(res.data || []);
setError(null);
})
.catch((err) => {
console.error('Failed to fetch projects', err);
setError('Failed to load projects. Please try again.');
})
.finally(() => setLoading(false));
};
useEffect(() => {
if (!user) return;
if (role === 'EMPLOYEE') {
setError('Access denied. Only managers and admins can view this page.');
setLoading(false);
return;
}
fetchProjects();
}, [user, filter]);
const handleEdit = (project: Project) => {
setEditData(project);
setModalOpen(true);
};
const handleNew = () => {
setEditData(null);
setModalOpen(true);
};
const handleDelete = async () => {
if (!deleteId) return;
try {
await projectService.deleteProject(deleteId);
setDeleteId(null);
fetchProjects();
} catch {
alert('Failed to delete project.');
setDeleteId(null);
}
};
// Access denied for EMPLOYEE
if (role === 'EMPLOYEE') {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto text-center py-20">
<AlertCircle className="w-12 h-12 text-text-muted mx-auto mb-4" />
<h2 className="text-xl font-semibold text-text-main mb-2">Access Denied</h2>
<p className="text-text-muted text-sm">Only managers and admins can access this page.</p>
</div>
</div>
);
}
// Loading skeleton
if (loading) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<div className="h-8 w-36 bg-gray-200 rounded animate-pulse mb-2" />
<div className="h-4 w-64 bg-gray-200 rounded animate-pulse" />
</div>
<div className="h-10 w-36 bg-gray-200 rounded-xl animate-pulse" />
</div>
<div className="bg-surface rounded-2xl border border-border shadow-card overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center gap-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-8 w-20 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
<div className="p-6 space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-12 bg-gray-200 rounded animate-pulse" />
))}
</div>
</div>
</div>
</div>
);
}
// Error state
if (error && !projects.length) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Projects</h2>
<p className="text-text-muted mt-1 text-sm">Manage SI/SM project contracts and details.</p>
</div>
</div>
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-2xl p-6 sm:p-8 text-center">
<p className="font-medium">{error}</p>
</div>
</div>
</div>
);
}
// Filtered + searched
const filtered = projects.filter((p) => {
const matchSearch = search.trim()
? p.projectName.toLowerCase().includes(search.toLowerCase()) ||
p.contractCompany.toLowerCase().includes(search.toLowerCase())
: true;
return matchSearch;
});
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Projects</h2>
<p className="text-text-muted mt-1 text-sm">Manage SI/SM project contracts and details.</p>
</div>
<button
onClick={handleNew}
className="w-full sm:w-auto bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold shadow-soft transition-all flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
New Project
</button>
</div>
<div className="bg-surface rounded-2xl border border-border shadow-card overflow-hidden">
{/* Filters bar */}
<div className="px-4 sm:px-6 py-4 border-b border-border flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
{statusFilterOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setFilter(opt.value)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
filter === opt.value
? 'bg-primary text-white'
: 'text-text-muted hover:bg-gray-100'
}`}
>
{opt.label}
</button>
))}
</div>
<div className="relative w-full sm:w-56">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or company..."
className="w-full pl-9 pr-3 py-1.5 rounded-lg bg-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50">
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Name</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Contract Company</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Status</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Period</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Manager</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody className="text-sm divide-y divide-gray-100">
{filtered.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center text-text-muted">
No projects found.
</td>
</tr>
)}
{filtered.map((project) => (
<tr key={project.projectId} className="hover:bg-gray-50 transition-colors">
<td className="py-4 px-4 sm:px-6">
<div>
<p className="font-medium text-text-main">{project.projectName}</p>
<p className="text-xs text-text-muted">{project.projectType}</p>
</div>
</td>
<td className="py-4 px-4 sm:px-6 text-text-muted">{project.contractCompany}</td>
<td className="py-4 px-4 sm:px-6">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${statusColors[project.status]}`}>
{statusLabels[project.status]}
</span>
</td>
<td className="py-4 px-4 sm:px-6 text-text-muted whitespace-nowrap">
{project.startDate} ~ {project.endDate || '—'}
</td>
<td className="py-4 px-4 sm:px-6 text-text-muted">
{project.internalManager?.name || '—'}
</td>
<td className="py-4 px-4 sm:px-6">
<div className="flex items-center gap-1">
<button
onClick={() => handleEdit(project)}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
title="Edit"
>
<Pencil className="w-3.5 h-3.5 text-text-muted hover:text-primary" />
</button>
{role === 'ADMIN' && (
<button
onClick={() => setDeleteId(project.projectId)}
className="p-1.5 hover:bg-red-50 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5 text-text-muted hover:text-status-critical-text" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<ProjectFormModal
isOpen={modalOpen}
onClose={() => { setModalOpen(false); setEditData(null); }}
onSaved={fetchProjects}
editData={editData}
/>
{/* Delete confirmation dialog */}
{deleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setDeleteId(null)}>
<div className="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
<h3 className="font-outfit font-semibold text-lg text-text-main mb-2">Delete Project</h3>
<p className="text-sm text-text-muted mb-4">Are you sure you want to delete this project? This action cannot be undone.</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteId(null)}
className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 rounded-xl text-sm font-semibold bg-status-critical hover:bg-red-700 text-white transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Settings, Plus, Pencil, Trash2, X, Check } from 'lucide-react';
import { useAuthStore } from '@/stores';
import apiClient from '@/services/apiClient';
interface CommonCode {
codeId: number;
groupCode: string;
codeValue: string;
codeName: string;
optionVal1?: string;
optionVal2?: string;
sortOrder: number;
isActive: string;
}
interface CommonCodeForm {
groupCode: string;
codeValue: string;
codeName: string;
optionVal1: string;
optionVal2: string;
sortOrder: number;
isActive: 'Y' | 'N';
}
export default function CommonCodesPage() {
const user = useAuthStore((s) => s.user);
const [groupCodes, setGroupCodes] = useState<string[]>([]);
const [selectedGroup, setSelectedGroup] = useState('');
const [codes, setCodes] = useState<CommonCode[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editItem, setEditItem] = useState<CommonCode | null>(null);
const [form, setForm] = useState<CommonCodeForm>({
groupCode: '',
codeValue: '',
codeName: '',
optionVal1: '',
optionVal2: '',
sortOrder: 1,
isActive: 'Y'
});
useEffect(() => {
apiClient.get('/common-codes').then((res: any) => {
if (res.success) {
const groups = [...new Set(res.data.map((c: CommonCode) => c.groupCode))] as string[];
setGroupCodes(groups);
if (groups.length > 0) setSelectedGroup(groups[0]);
}
}).catch(() => {}).finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!selectedGroup) return;
apiClient.get('/common-codes', { params: { groupCode: selectedGroup } }).then((res: any) => {
if (res.success) setCodes(res.data);
}).catch(() => {});
}, [selectedGroup]);
const openCreate = () => {
setEditItem(null);
setForm({ groupCode: selectedGroup, codeValue: '', codeName: '', optionVal1: '', optionVal2: '', sortOrder: 1, isActive: 'Y' });
setShowModal(true);
};
const openEdit = (item: CommonCode) => {
setEditItem(item);
setForm({
groupCode: item.groupCode,
codeValue: item.codeValue,
codeName: item.codeName,
optionVal1: item.optionVal1 || '',
optionVal2: item.optionVal2 || '',
sortOrder: item.sortOrder,
isActive: item.isActive as 'Y' | 'N'
});
setShowModal(true);
};
const handleSave = async () => {
try {
let res: any;
if (editItem) {
res = await apiClient.put(`/common-codes/${editItem.codeId}`, form);
} else {
res = await apiClient.post('/common-codes', form);
}
if (res?.success) {
setShowModal(false);
const refreshRes: any = await apiClient.get('/common-codes', { params: { groupCode: selectedGroup } });
if (refreshRes?.success) setCodes(refreshRes.data);
}
} catch (e) {
console.error('Save error:', e);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this code?')) return;
try {
await apiClient.delete(`/common-codes/${id}`);
const refreshRes: any = await apiClient.get('/common-codes', { params: { groupCode: selectedGroup } });
if (refreshRes?.success) setCodes(refreshRes.data);
} catch (e) {
console.error('Delete error:', e);
}
};
if (user?.roleCode !== 'ADMIN') {
return <div className="p-4 sm:p-6 lg:p-8 text-center text-text-muted">Access denied. Admin only.</div>;
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Settings className="w-6 h-6 text-primary" />
<div>
<h2 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">Common Codes</h2>
<p className="text-text-muted text-sm">Manage system code tables</p>
</div>
</div>
<button
onClick={openCreate}
className="bg-primary text-white px-4 py-2 rounded-xl text-xs font-semibold hover:bg-primary-hover transition-all flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5" /> Add Code
</button>
</div>
<div className="flex gap-2 mb-4 flex-wrap">
{groupCodes.map(g => (
<button
key={g}
onClick={() => setSelectedGroup(g)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedGroup === g ? 'bg-primary text-white' : 'bg-gray-100 text-text-muted hover:bg-gray-200'
}`}>
{g}
</button>
))}
</div>
{loading ? (
<div className="text-center py-12 text-text-muted">Loading...</div>
) : (
<div className="bg-surface rounded-2xl shadow-card border border-border overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50 border-b border-border text-[10px] font-bold text-text-muted uppercase tracking-widest">
<th className="px-4 sm:px-6 py-3">Code Value</th>
<th className="px-4 sm:px-6 py-3">Code Name</th>
<th className="px-4 sm:px-6 py-3 hidden sm:table-cell">Option1</th>
<th className="px-4 sm:px-6 py-3 hidden md:table-cell">Option2</th>
<th className="px-4 sm:px-6 py-3 hidden lg:table-cell">Order</th>
<th className="px-4 sm:px-6 py-3">Active</th>
<th className="px-4 sm:px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border text-sm">
{codes.map((c) => (
<tr key={c.codeId} className="hover:bg-gray-50/30">
<td className="px-4 sm:px-6 py-3 font-mono text-xs font-medium">{c.codeValue}</td>
<td className="px-4 sm:px-6 py-3">{c.codeName}</td>
<td className="px-4 sm:px-6 py-3 hidden sm:table-cell text-text-muted">{c.optionVal1 || '-'}</td>
<td className="px-4 sm:px-6 py-3 hidden md:table-cell text-text-muted">{c.optionVal2 || '-'}</td>
<td className="px-4 sm:px-6 py-3 hidden lg:table-cell text-text-muted">{c.sortOrder}</td>
<td className="px-4 sm:px-6 py-3">
{c.isActive === 'Y' ? (
<Check className="w-4 h-4 text-status-resolved" />
) : (
<X className="w-4 h-4 text-status-critical" />
)}
</td>
<td className="px-4 sm:px-6 py-3 text-right">
<button onClick={() => openEdit(c)} className="p-1 hover:text-primary">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDelete(c.codeId)} className="p-1 hover:text-status-critical ml-1">
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
{codes.length === 0 && (
<div className="text-center py-12 text-text-muted">No codes found for this group.</div>
)}
</div>
)}
{showModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => setShowModal(false)}>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold mb-4">{editItem ? 'Edit' : 'New'} Common Code</h3>
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-text-muted">Group Code</label>
<input
value={form.groupCode}
onChange={e => setForm(f => ({ ...f, groupCode: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">Code Value</label>
<input
value={form.codeValue}
onChange={e => setForm(f => ({ ...f, codeValue: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">Code Name</label>
<input
value={form.codeName}
onChange={e => setForm(f => ({ ...f, codeName: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">Option 1</label>
<input
value={form.optionVal1}
onChange={e => setForm(f => ({ ...f, optionVal1: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">Option 2</label>
<input
value={form.optionVal2}
onChange={e => setForm(f => ({ ...f, optionVal2: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm"
/>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="text-xs font-medium text-text-muted">Sort Order</label>
<input
type="number"
value={form.sortOrder}
onChange={e => setForm(f => ({ ...f, sortOrder: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm"
/>
</div>
<div className="flex-1">
<label className="text-xs font-medium text-text-muted">Active</label>
<select
value={form.isActive}
onChange={e => setForm(f => ({ ...f, isActive: e.target.value as 'Y' | 'N' }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white">
<option value="Y">Yes</option>
<option value="N">No</option>
</select>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100">
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 rounded-xl text-sm font-medium bg-primary text-white hover:bg-primary-hover">
Save
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import TrainingDashboard from '@/components/training/TrainingDashboard';
export default function TrainingPage() {
return <TrainingDashboard />;
}
+586
View File
@@ -0,0 +1,586 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { FileText, Plus, Search, CheckCircle2, Clock, XCircle, Send, Eye, AlertCircle } from 'lucide-react';
import reportService from '@/services/reportService';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import type { WorkReport, ReportStatus } from '@/types';
export default function WorkReportsPage() {
const [reports, setReports] = useState<WorkReport[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<'list' | 'write'>('list');
const { codes: reportTypeCodes, loading: typeCodesLoading } = useCommonCodes('REPORT_TYPE');
const { codes: reportStatusCodes, loading: statusCodesLoading } = useCommonCodes('REPORT_STATUS');
const statusLabel = statusCodesLoading ? {} : Object.fromEntries(reportStatusCodes.map((c) => [c.codeValue, c.codeName]));
const statusColorMap = statusCodesLoading ? {} : Object.fromEntries(reportStatusCodes.map((c) => [c.codeValue, c.codeName === '임시저장' ? 'bg-gray-100 text-gray-700' : c.codeName === '제출됨' ? 'bg-status-pending-bg text-status-pending' : c.codeName === '승인' ? 'bg-status-resolved-bg text-status-resolved' : 'bg-status-critical-bg text-status-critical']));
const [srAutoComplete, setSrAutoComplete] = useState<string[]>([]);
const [form, setForm] = useState({
dispatchId: '',
reportType: 'WEEKLY',
targetDate: new Date().toISOString().split('T')[0],
completedTasks: '',
plannedTasks: '',
issuesAndRisks: '',
supportRequested: '',
});
const [submitStatus, setSubmitStatus] = useState<ReportStatus>('DRAFT');
const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [reviewStates, setReviewStates] = useState<Record<number, { comment: string; status: 'APPROVED' | 'REJECTED' }>>({});
const fetchReports = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await reportService.getAll({ size: 50 });
if (response.success && response.data) {
setReports(response.data.content);
} else {
setError(response.message || '보고서를 불러오는데 실패했습니다.');
}
} catch (err) {
setError(err instanceof Error ? err.message : '보고서를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
}, []);
const fetchSrAutoComplete = useCallback(async () => {
const targetDate = form.targetDate;
try {
const response = await reportService.getSrAutoComplete(targetDate);
if (response.success && response.data) {
setSrAutoComplete(response.data);
}
} catch (err) {
console.error('SR 자동완성 로딩 실패:', err);
}
}, [form.targetDate]);
useEffect(() => {
fetchReports();
}, [fetchReports]);
useEffect(() => {
if (selectedTab === 'write') {
fetchSrAutoComplete();
}
}, [selectedTab, fetchSrAutoComplete]);
const handleFormChange = (field: string, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
setFormError(null);
};
const handleCreateReport = async () => {
if (!form.dispatchId) {
setFormError('파견 이력을 선택해주세요.');
return;
}
if (!form.completedTasks.trim()) {
setFormError('완료 실적을 입력해주세요.');
return;
}
try {
setFormLoading(true);
setFormError(null);
const createData = {
dispatchId: parseInt(form.dispatchId, 10),
reportType: form.reportType as 'WEEKLY' | 'MONTHLY',
targetDate: form.targetDate,
completedTasks: form.completedTasks,
plannedTasks: form.plannedTasks,
issuesAndRisks: form.issuesAndRisks || null,
supportRequested: form.supportRequested || null,
};
const response = await reportService.create(createData);
if (response.success) {
if (submitStatus === 'SUBMITTED') {
const submittedReport = response.data;
await reportService.updateStatus(submittedReport.reportId, 'SUBMITTED');
}
alert(
submitStatus === 'SUBMITTED'
? '보고서가 제출되었습니다. 관리자 결재를 기다려주세요.'
: '보고서가 임시저장되었습니다.',
);
setForm({
dispatchId: '',
reportType: 'WEEKLY',
targetDate: new Date().toISOString().split('T')[0],
completedTasks: '',
plannedTasks: '',
issuesAndRisks: '',
supportRequested: '',
});
setSubmitStatus('DRAFT');
setSelectedTab('list');
await fetchReports();
} else {
setFormError(response.message || '보고서 작성에 실패했습니다.');
}
} catch (err) {
setFormError(err instanceof Error ? err.message : '보고서 작성에 실패했습니다.');
} finally {
setFormLoading(false);
}
};
const handleSubmit = async (reportId: number) => {
try {
const response = await reportService.updateStatus(reportId, 'SUBMITTED');
if (response.success) {
alert('보고서가 제출되었습니다. 관리자 결재를 기다려주세요.');
await fetchReports();
} else {
alert(response.message || '제출에 실패했습니다.');
}
} catch (err) {
alert(err instanceof Error ? err.message : '제출에 실패했습니다.');
}
};
const handleReview = async (reportId: number, action: 'APPROVED' | 'REJECTED') => {
const reviewData = reviewStates[reportId] || { comment: '', status: action };
if (action === 'REJECTED' && !reviewData.comment.trim()) {
alert('반려 사유를 입력해주세요.');
return;
}
try {
const response = await reportService.review(reportId, action, reviewData.comment);
if (response.success) {
alert(action === 'APPROVED' ? '승인되었습니다.' : '반려되었습니다.');
setReviewStates((prev) => {
const next = { ...prev };
delete next[reportId];
return next;
});
await fetchReports();
} else {
alert(response.message || '결재 처리에 실패했습니다.');
}
} catch (err) {
alert(err instanceof Error ? err.message : '결재 처리에 실패했습니다.');
}
};
const handleReviewCommentChange = (reportId: number, comment: string) => {
setReviewStates((prev) => ({
...prev,
[reportId]: { ...prev[reportId], comment, status: prev[reportId]?.status || 'REJECTED' },
}));
};
const stats = {
total: reports.length,
approved: reports.filter((r) => r.status === 'APPROVED').length,
pending: reports.filter((r) => r.status === 'SUBMITTED').length,
rejected: reports.filter((r) => r.status === 'REJECTED').length,
};
if (loading) {
return (
<div className="p-4 sm:p-6 lg:p-8 flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<Clock className="w-12 h-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-text-muted">보고서를 불러오는 중...</p>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Work Reports</h2>
<p className="text-text-muted mt-1 text-sm">주간/월간 업무 보고서를 작성하고 결재 상태를 추적합니다.</p>
</div>
<div className="flex flex-wrap gap-3 w-full sm:w-auto">
<button
onClick={() => setSelectedTab('list')}
className={`flex-1 sm:flex-none px-4 py-2.5 rounded-xl text-sm font-semibold transition-all flex items-center justify-center ${
selectedTab === 'list'
? 'bg-primary text-white shadow-soft'
: 'border border-border text-text-main hover:bg-gray-50'
}`}
>
<FileText className="w-4 h-4 inline mr-1.5" />내 보고서
</button>
<button
onClick={() => setSelectedTab('write')}
className={`flex-1 sm:flex-none px-4 py-2.5 rounded-xl text-sm font-semibold transition-all flex items-center justify-center ${
selectedTab === 'write'
? 'bg-primary text-white shadow-soft'
: 'border border-border text-text-main hover:bg-gray-50'
}`}
>
<Plus className="w-4 h-4 inline mr-1.5" />새 보고서
</button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs text-text-muted">전체</p>
<p className="font-outfit text-2xl font-semibold text-text-main">{stats.total}</p>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs text-text-muted">승인</p>
<p className="font-outfit text-2xl font-semibold text-status-resolved">{stats.approved}</p>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs text-text-muted">대기</p>
<p className="font-outfit text-2xl font-semibold text-status-pending">{stats.pending}</p>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border">
<p className="text-xs text-text-muted">반려</p>
<p className="font-outfit text-2xl font-semibold text-status-critical">{stats.rejected}</p>
</div>
</div>
{error ? (
<div className="bg-status-critical-bg border border-status-critical rounded-2xl p-6 mb-6">
<div className="flex items-start">
<AlertCircle className="w-5 h-5 text-status-critical mt-0.5 mr-3" />
<div>
<p className="text-status-critical font-semibold mb-1">데이터 로딩 오류</p>
<p className="text-status-critical text-sm">{error}</p>
<button
onClick={fetchReports}
className="mt-3 text-xs font-semibold text-status-critical underline hover:no-underline"
>
다시 시도
</button>
</div>
</div>
</div>
) : null}
{selectedTab === 'list' ? (
reports.length === 0 ? (
<div className="bg-surface rounded-2xl border border-border p-8 sm:p-12 text-center">
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-text-main font-semibold mb-2">보고서가 없습니다</p>
<p className="text-text-muted text-sm mb-4">첫 번째 업무 보고서를 작성해보세요</p>
<button
onClick={() => setSelectedTab('write')}
className="px-6 py-2.5 bg-primary text-white rounded-xl text-sm font-semibold hover:bg-primary-hover transition-all"
>
<Plus className="w-4 h-4 inline mr-1.5" />보고서 작성
</button>
</div>
) : (
<div className="bg-surface rounded-2xl border border-border shadow-card overflow-hidden">
{/* Desktop table */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50">
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">ID</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Type</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Date</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Author</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">Status</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase text-right whitespace-nowrap">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm">
{reports.map((r) => (
<tr key={r.reportId} className="hover:bg-gray-50 transition-colors">
<td className="py-4 px-4 sm:px-6 font-medium text-text-main">{r.reportId}</td>
<td className="py-4 px-4 sm:px-6 text-text-muted whitespace-nowrap">{r.reportType}</td>
<td className="py-4 px-4 sm:px-6 text-text-muted whitespace-nowrap">{r.targetDate}</td>
<td className="py-4 px-4 sm:px-6 text-text-muted">{r.userName || 'N/A'}</td>
<td className="py-4 px-4 sm:px-6">
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
statusColorMap[r.status as keyof typeof statusColorMap] || 'bg-gray-100 text-gray-700'
}`}
>
{statusLabel[r.status as keyof typeof statusLabel] || r.status}
</span>
</td>
<td className="py-4 px-4 sm:px-6 text-right">
{r.status === 'SUBMITTED' ? (
<div className="flex justify-end space-x-1">
<button
onClick={() => handleReview(r.reportId, 'APPROVED')}
className="p-1.5 text-status-resolved hover:bg-green-50 rounded-lg"
title="승인"
>
<CheckCircle2 className="w-4 h-4" />
</button>
<button
onClick={() => handleReview(r.reportId, 'REJECTED')}
className="p-1.5 text-status-critical hover:bg-red-50 rounded-lg"
title="반려"
>
<XCircle className="w-4 h-4" />
</button>
</div>
) : r.status === 'DRAFT' ? (
<button
onClick={() => handleSubmit(r.reportId)}
className="p-1.5 text-primary hover:bg-indigo-50 rounded-lg"
title="제출"
>
<Send className="w-4 h-4" />
</button>
) : (
<button onClick={() => alert(`Report #${r.reportId}\nStatus: ${r.status}\nDate: ${r.targetDate}\n\nCompleted:\n${r.completedTasks}\n\nPlanned:\n${r.plannedTasks}${r.issuesAndRisks ? `\n\nIssues:\n${r.issuesAndRisks}` : ''}${r.reviewComment ? `\n\nReview: ${r.reviewComment}` : ''}`)} className="p-1.5 text-text-muted hover:text-primary rounded-lg" title="조회">
<Eye className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden space-y-3 p-4">
{reports.map((r) => (
<div key={r.reportId} className="bg-surface rounded-2xl border border-border p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-text-muted">#{r.reportId}</span>
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${statusColorMap[r.status as keyof typeof statusColorMap] || ''}`}>{statusLabel[r.status as keyof typeof statusLabel] || r.status}</span>
</div>
<p className="text-sm font-medium text-text-main">{r.reportType} · {r.targetDate}</p>
<p className="text-xs text-text-muted line-clamp-2">{r.completedTasks}</p>
<div className="flex items-center gap-2 pt-2 border-t border-border">
{r.status === 'SUBMITTED' ? (
<>
<button onClick={() => handleReview(r.reportId, 'APPROVED')} className="flex-1 py-2 bg-green-50 text-status-resolved rounded-xl text-xs font-medium flex items-center justify-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> 승인</button>
<button onClick={() => handleReview(r.reportId, 'REJECTED')} className="flex-1 py-2 bg-red-50 text-status-critical rounded-xl text-xs font-medium flex items-center justify-center gap-1"><XCircle className="w-3.5 h-3.5" /> 반려</button>
</>
) : r.status === 'DRAFT' ? (
<button onClick={() => handleSubmit(r.reportId)} className="flex-1 py-2 bg-indigo-50 text-primary rounded-xl text-xs font-medium flex items-center justify-center gap-1"><Send className="w-3.5 h-3.5" /> 제출</button>
) : (
<button onClick={() => alert(`Report #${r.reportId}\nStatus: ${r.status}\nDate: ${r.targetDate}\n\nCompleted:\n${r.completedTasks}\n\nPlanned:\n${r.plannedTasks}${r.issuesAndRisks ? `\n\nIssues:\n${r.issuesAndRisks}` : ''}${r.reviewComment ? `\n\nReview: ${r.reviewComment}` : ''}`)} className="flex-1 py-2 bg-gray-50 text-text-muted rounded-xl text-xs font-medium flex items-center justify-center gap-1"><Eye className="w-3.5 h-3.5" /> 조회</button>
)}
</div>
</div>
))}
</div>
</div>
)
) : (
/* Write Report Form */
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6 pb-4 border-b border-border">
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
<select
value={form.reportType}
onChange={(e) => handleFormChange('reportType', e.target.value)}
className="text-sm bg-gray-50 border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary"
>
{typeCodesLoading ? (
<option value="">Loading types...</option>
) : (
reportTypeCodes.filter((c) => c.codeValue !== 'DAILY').map((code) => (
<option key={code.codeValue} value={code.codeValue}>
{code.codeName} ({code.codeValue})
</option>
))
)}
</select>
<input
type="date"
value={form.targetDate}
onChange={(e) => handleFormChange('targetDate', e.target.value)}
className="text-sm bg-gray-50 border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary"
/>
<select
value={form.dispatchId}
onChange={(e) => handleFormChange('dispatchId', e.target.value)}
className="text-sm bg-gray-50 border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary flex-1 sm:flex-none"
>
<option value="">파견 이력 선택</option>
<option value="1">Samsung Pay SI - 2024.01~</option>
<option value="2">LG CNS SM - 2023.06~2023.12</option>
</select>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button
onClick={() => setSubmitStatus('DRAFT')}
className={`flex-1 sm:flex-none px-4 py-2 rounded-xl text-xs font-semibold transition-all ${
submitStatus === 'DRAFT'
? 'bg-gray-200 text-text-main'
: 'border border-border text-text-main hover:bg-gray-50'
}`}
>
임시저장
</button>
<button
onClick={() => setSubmitStatus('SUBMITTED')}
className={`flex-1 sm:flex-none px-4 py-2 rounded-xl text-xs font-semibold transition-all ${
submitStatus === 'SUBMITTED'
? 'bg-primary text-white'
: 'border border-primary text-primary hover:bg-indigo-50'
}`}
>
제출하기
</button>
</div>
</div>
{/* SR Auto-complete */}
<div className="mb-6 p-4 bg-indigo-50/50 rounded-xl border border-indigo-100/50">
<div className="flex items-center text-xs font-semibold text-indigo-600 mb-2">
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" /> SR 자동 연동 실적 (해당 주차)
</div>
<div className="space-y-1">
{srAutoComplete.length > 0 ? (
srAutoComplete.map((sr, i) => (
<div key={i} className="text-xs text-text-main flex items-center">
<div className="w-1.5 h-1.5 rounded-full bg-primary mr-2" />
{sr}
</div>
))
) : (
<p className="text-xs text-text-muted">연동된 SR 내역이 없습니다.</p>
)}
</div>
</div>
{formError && (
<div className="mb-4 p-3 bg-status-critical-bg border border-status-critical rounded-xl">
<p className="text-xs text-status-critical flex items-center">
<AlertCircle className="w-3.5 h-3.5 mr-1.5" />
{formError}
</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">완료 실적</label>
<textarea
value={form.completedTasks}
onChange={(e) => handleFormChange('completedTasks', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={3}
placeholder="금주 완료한 업무 내역을 입력하세요..."
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">차기 계획</label>
<textarea
value={form.plannedTasks}
onChange={(e) => handleFormChange('plannedTasks', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={3}
placeholder="차주 예정 업무 계획을 입력하세요..."
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">특이사항/리스크</label>
<textarea
value={form.issuesAndRisks}
onChange={(e) => handleFormChange('issuesAndRisks', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={2}
placeholder="현장 리스크가 있다면..."
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">본사 지원 요청</label>
<textarea
value={form.supportRequested}
onChange={(e) => handleFormChange('supportRequested', e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={2}
placeholder="본사 지원이 필요하면..."
/>
</div>
</div>
</div>
<div className="mt-6 flex flex-col sm:flex-row justify-end gap-3">
<button
onClick={() => setSelectedTab('list')}
className="w-full sm:w-auto px-6 py-2.5 border border-border rounded-xl text-sm font-semibold text-text-main hover:bg-gray-50 transition-all"
>
취소
</button>
<button
onClick={handleCreateReport}
disabled={formLoading}
className="w-full sm:w-auto px-6 py-2.5 bg-primary text-white rounded-xl text-sm font-semibold hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{formLoading ? (
<Clock className="w-4 h-4 mr-1.5 animate-spin" />
) : submitStatus === 'SUBMITTED' ? (
<Send className="w-4 h-4 mr-1.5" />
) : null}
{formLoading ? '처리 중...' : submitStatus === 'SUBMITTED' ? '제출하기' : '임시저장'}
</button>
</div>
</div>
)}
{/* Review Modal for REJECTED */}
{Object.entries(reviewStates).map(([reportIdStr, state]) => {
const reportId = parseInt(reportIdStr, 10);
const report = reports.find((r) => r.reportId === reportId);
if (!report || state.status !== 'REJECTED') return null;
return (
<div key={reportId} className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-surface rounded-2xl p-6 w-full max-w-md">
<h3 className="font-outfit text-xl font-semibold text-text-main mb-4">반려 사유 입력</h3>
<textarea
value={state.comment}
onChange={(e) => handleReviewCommentChange(reportId, e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none mb-4"
rows={4}
placeholder="반려 사유를 구체적으로 입력해주세요..."
autoFocus
/>
<div className="flex justify-end space-x-3">
<button
onClick={() =>
setReviewStates((prev) => {
const next = { ...prev };
delete next[reportId];
return next;
})
}
className="px-4 py-2 border border-border rounded-xl text-sm font-semibold text-text-main hover:bg-gray-50 transition-all"
>
취소
</button>
<button
onClick={() => handleReview(reportId, 'REJECTED')}
className="px-4 py-2 bg-status-critical text-white rounded-xl text-sm font-semibold hover:bg-red-600 transition-all"
>
반려 처리
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
+237
View File
@@ -0,0 +1,237 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, CheckCircle, XCircle, Eye, Search, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores';
import reportService from '@/services/reportService';
import type { WorkReport, ReportSummary } from '@/types';
const statusBadge = (status: string) => {
const map: Record<string, { label: string; cls: string }> = {
DRAFT: { label: 'Draft', cls: 'bg-gray-100 text-gray-600' },
SUBMITTED: { label: 'Submitted', cls: 'bg-status-pending-bg text-status-pending-text' },
APPROVED: { label: 'Approved', cls: 'bg-status-resolved-bg text-status-resolved-text' },
REJECTED: { label: 'Rejected', cls: 'bg-status-critical-bg text-status-critical-text' },
};
const m = map[status] || { label: status, cls: 'bg-gray-100 text-gray-600' };
return <span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${m.cls}`}>{m.label}</span>;
};
export default function ReportReviewPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const [summary, setSummary] = useState<ReportSummary | null>(null);
const [reports, setReports] = useState<WorkReport[]>([]);
const [filter, setFilter] = useState('SUBMITTED');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [rejectModal, setRejectModal] = useState<{ open: boolean; reportId: number; comment: string }>({ open: false, reportId: 0, comment: '' });
const fetchData = async () => {
try {
const [summaryRes, listRes] = await Promise.all([
reportService.getSummary(),
reportService.getAll({ status: filter, size: 100 }),
]);
setSummary(summaryRes.data);
setReports(listRes.data.content || []);
} catch {
// ignore
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [filter]);
const handleApprove = async (reportId: number) => {
try {
await reportService.review(reportId, 'APPROVED', '');
fetchData();
} catch { alert('Failed to approve report.'); }
};
const handleReject = async () => {
if (!rejectModal.comment.trim()) return;
try {
await reportService.review(rejectModal.reportId, 'REJECTED', rejectModal.comment);
setRejectModal({ open: false, reportId: 0, comment: '' });
fetchData();
} catch { alert('Failed to reject report.'); }
};
if (!user || user.roleCode === 'EMPLOYEE') {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto text-center py-20">
<AlertCircle className="w-12 h-12 text-text-muted mx-auto mb-4" />
<h2 className="text-xl font-semibold text-text-main mb-2">Access Denied</h2>
<p className="text-text-muted text-sm">Only managers and admins can access this page.</p>
</div>
</div>
);
}
const filtered = reports.filter(r =>
r.userName?.toLowerCase().includes(search.toLowerCase()) ||
r.reportType?.toLowerCase().includes(search.toLowerCase())
);
const statCards = [
{ label: 'Submitted', value: summary?.submitted ?? 0, color: 'text-status-pending-text', bg: 'bg-status-pending-bg' },
{ label: 'Approved', value: summary?.approved ?? 0, color: 'text-status-resolved-text', bg: 'bg-status-resolved-bg' },
{ label: 'Rejected', value: summary?.rejected ?? 0, color: 'text-status-critical-text', bg: 'bg-status-critical-bg' },
{ label: 'Draft', value: summary?.draft ?? 0, color: 'text-text-muted', bg: 'bg-gray-100' },
];
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main">Report Review Dashboard</h2>
<p className="text-text-muted text-sm mt-1">Approve or reject submitted work reports</p>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{statCards.map((s) => (
<div key={s.label} className="bg-surface rounded-2xl p-5 shadow-card border border-border">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center ${s.color}`}>
<FileText className="w-5 h-5" />
</div>
<div>
<p className="text-xs text-text-muted font-medium">{s.label}</p>
<p className={`font-outfit text-2xl font-semibold ${s.color}`}>{s.value}</p>
</div>
</div>
</div>
))}
</div>
<div className="bg-surface rounded-2xl shadow-card border border-border overflow-hidden">
<div className="p-4 sm:p-6 border-b border-border flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
<div className="flex items-center gap-2">
{['SUBMITTED', 'APPROVED', 'REJECTED', 'DRAFT'].map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${filter === s ? 'bg-primary text-white' : 'bg-gray-100 text-text-muted hover:bg-gray-200'}`}
>
{s.charAt(0) + s.slice(1).toLowerCase()}
</button>
))}
</div>
<div className="relative w-full sm:w-64">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or type..."
className="w-full pl-9 pr-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50/50">
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Employee</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Type</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Date</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Status</th>
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted tracking-wider uppercase border-b border-border">Actions</th>
</tr>
</thead>
<tbody className="text-sm divide-y divide-gray-100">
{loading && (
<tr><td colSpan={5} className="py-8 text-center text-text-muted">Loading...</td></tr>
)}
{!loading && filtered.length === 0 && (
<tr><td colSpan={5} className="py-8 text-center text-text-muted">No reports found.</td></tr>
)}
{filtered.map((r) => (
<tr key={r.reportId} className="hover:bg-gray-50 transition-colors">
<td className="py-4 px-4 sm:px-6 font-medium text-text-main">{r.userName}</td>
<td className="py-4 px-4 sm:px-6">
<span className="text-xs font-medium bg-indigo-50 text-primary px-2 py-1 rounded-lg">{r.reportType}</span>
</td>
<td className="py-4 px-4 sm:px-6 text-text-muted">{r.targetDate}</td>
<td className="py-4 px-4 sm:px-6">{statusBadge(r.status)}</td>
<td className="py-4 px-4 sm:px-6">
<div className="flex items-center gap-2">
{r.status === 'SUBMITTED' && (
<>
<button
onClick={() => handleApprove(r.reportId)}
className="p-1.5 rounded-lg bg-status-resolved-bg text-status-resolved-text hover:bg-green-100 transition-colors"
title="Approve"
>
<CheckCircle className="w-4 h-4" />
</button>
<button
onClick={() => setRejectModal({ open: true, reportId: r.reportId, comment: '' })}
className="p-1.5 rounded-lg bg-status-critical-bg text-status-critical-text hover:bg-red-100 transition-colors"
title="Reject"
>
<XCircle className="w-4 h-4" />
</button>
</>
)}
<button
onClick={() => router.push(`/work-reports`)}
className="p-1.5 rounded-lg bg-gray-100 text-text-muted hover:bg-gray-200 transition-colors"
title="View"
>
<Eye className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{rejectModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setRejectModal({ ...rejectModal, open: false })}>
<div className="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<h3 className="font-outfit font-semibold text-lg text-text-main mb-2">Reject Report</h3>
<p className="text-sm text-text-muted mb-4">Please provide feedback for the rejection.</p>
<textarea
value={rejectModal.comment}
onChange={(e) => setRejectModal({ ...rejectModal, comment: e.target.value })}
placeholder="Required: reason for rejection..."
className="w-full px-3 py-2.5 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={4}
autoFocus
/>
<div className="flex justify-end gap-3 mt-4">
<button
onClick={() => setRejectModal({ open: false, reportId: 0, comment: '' })}
className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
onClick={handleReject}
disabled={!rejectModal.comment.trim()}
className="px-4 py-2 rounded-xl text-sm font-semibold bg-status-critical-text text-white hover:bg-red-700 transition-colors disabled:opacity-50"
>
Reject
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+64
View File
@@ -0,0 +1,64 @@
@import "tailwindcss";
@theme {
--color-canvas: #F2F3F8;
--color-primary: #4F46E5;
--color-primary-hover: #3730A3;
--color-surface: #ffffff;
--color-border: #E5E7EB;
--color-text-main: #0b1c30;
--color-text-muted: #6B7280;
/* Semantic Status */
--color-status-open-bg: #EFF6FF;
--color-status-open-text: #3B82F6;
--color-status-resolved-bg: #ECFDF5;
--color-status-resolved-text: #10B981;
--color-status-pending-bg: #FFFBEB;
--color-status-pending-text: #F59E0B;
--color-status-critical-bg: #FEF2F2;
--color-status-critical-text: #EF4444;
--color-status-closed-bg: #F9FAFB;
--color-status-closed-text: #6B7280;
/* Surface Container */
--color-surface-container: #e5eeff;
--color-surface-container-high: #dce9ff;
--color-surface-container-highest: #d3e4fe;
/* Project Dots */
--color-dot-green: #22C55E;
--color-dot-orange: #F97316;
--color-dot-blue: #3B82F6;
--color-dot-pink: #EC4899;
--color-dot-purple: #A855F7;
--font-inter: var(--font-inter);
--font-outfit: var(--font-outfit);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-float: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-modal: 0 20px 48px rgba(0, 0, 0, 0.14);
}
@layer base {
body {
@apply font-inter bg-canvas text-text-main;
}
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Inter, Outfit } from "next/font/google";
import ParkingGuard from "@/components/layout/ParkingGuard";
import "./globals.css";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
const outfit = Outfit({
variable: "--font-outfit",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "SR System - HQ Helpdesk",
description: "SM/SI 파견 직원 통합 지원 및 IT 자산 관리 포털",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko" className={`${inter.variable} ${outfit.variable} h-full antialiased`}>
<body className="h-full font-inter bg-canvas text-text-main overflow-hidden">
<ParkingGuard>
{children}
</ParkingGuard>
</body>
</html>
);
}
@@ -0,0 +1,233 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { X, Search } from 'lucide-react';
import dispatchHistoryService from '@/services/dispatchHistoryService';
import employeeService from '@/services/employeeService';
import projectService from '@/services/projectService';
import type { EmployeeSearchResult, Project } from '@/types';
interface Props {
isOpen: boolean;
onClose: () => void;
onSaved: () => void;
editData?: Record<string, unknown> | null;
}
export default function DispatchFormModal({ isOpen, onClose, onSaved, editData }: Props) {
const [empSearch, setEmpSearch] = useState('');
const [empResults, setEmpResults] = useState<EmployeeSearchResult[]>([]);
const [selectedEmp, setSelectedEmp] = useState<EmployeeSearchResult | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [roleTitle, setRoleTitle] = useState('');
const [contributionRate, setContributionRate] = useState(50);
const [individualTech, setIndividualTech] = useState('');
const [performedTasks, setPerformedTasks] = useState('');
const [loading, setLoading] = useState(false);
const [showEmpDropdown, setShowEmpDropdown] = useState(false);
const debounceRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!isOpen) return;
projectService.getAll().then((res) => {
setProjects(res.data || []);
}).catch(() => {});
}, [isOpen]);
useEffect(() => {
if (!editData) return;
/* eslint-disable react-hooks/set-state-in-effect */
setRoleTitle((editData.roleTitle as string) || '');
setContributionRate((editData.contributionRate as number) || 50);
setIndividualTech((editData.individualTech as string) || '');
setPerformedTasks((editData.performedTasks as string) || '');
setStartDate((editData.startDate as string) || '');
setEndDate((editData.endDate as string) || '');
/* eslint-enable react-hooks/set-state-in-effect */
}, [editData]);
const handleEmpSearch = (val: string) => {
setEmpSearch(val);
setShowEmpDropdown(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(() => {
if (val.trim().length >= 1) {
employeeService.search(val.trim()).then((res) => {
setEmpResults(res.data || []);
}).catch(() => {});
} else {
setEmpResults([]);
}
}, 300);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedEmp || !selectedProject) return;
setLoading(true);
try {
const payload = {
employeeId: selectedEmp.empId,
projectId: selectedProject.projectId,
roleTitle,
contributionRate,
individualTech,
performedTasks,
startDate: startDate || undefined,
endDate: endDate || undefined,
};
await dispatchHistoryService.create(payload);
onSaved();
onClose();
} catch {
alert('Failed to save dispatch.');
}
setLoading(false);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-6 border-b border-border">
<h3 className="font-outfit font-semibold text-lg text-text-main">New Dispatch</h3>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg transition-colors">
<X className="w-5 h-5 text-text-muted" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="relative">
<label className="block text-xs font-medium text-text-muted mb-1.5">Employee</label>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={empSearch}
onChange={(e) => handleEmpSearch(e.target.value)}
onFocus={() => setShowEmpDropdown(true)}
placeholder="Search employee name..."
className="w-full pl-9 pr-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{showEmpDropdown && empResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border border-border rounded-xl shadow-lg max-h-48 overflow-y-auto">
{empResults.map((e) => (
<button
key={e.empId}
type="button"
onClick={() => { setSelectedEmp(e); setEmpSearch(e.name); setShowEmpDropdown(false); }}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 transition-colors"
>
{e.name} <span className="text-text-muted text-xs">({e.email})</span>
</button>
))}
</div>
)}
{selectedEmp && !showEmpDropdown && (
<p className="text-xs text-primary mt-1">Selected: {selectedEmp.name}</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Project</label>
<select
value={selectedProject?.projectId || ''}
onChange={(e) => {
const p = projects.find((p) => p.projectId === Number(e.target.value));
setSelectedProject(p || null);
}}
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.projectId} value={p.projectId}>{p.projectName}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Role Title</label>
<input
type="text"
value={roleTitle}
onChange={(e) => setRoleTitle(e.target.value)}
placeholder="e.g. Backend Developer"
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Contribution Rate: {contributionRate}%</label>
<input
type="range"
min={0}
max={100}
value={contributionRate}
onChange={(e) => setContributionRate(Number(e.target.value))}
className="w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Tech Stack</label>
<textarea
value={individualTech}
onChange={(e) => setIndividualTech(e.target.value)}
placeholder="e.g. Java, Spring Boot, React..."
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={2}
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Performed Tasks</label>
<textarea
value={performedTasks}
onChange={(e) => setPerformedTasks(e.target.value)}
placeholder="Key tasks and responsibilities..."
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={3}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100 transition-colors">Cancel</button>
<button
type="submit"
disabled={loading || !selectedEmp || !selectedProject || !roleTitle.trim()}
className="px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : 'Create Dispatch'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,182 @@
"use client";
import React from 'react';
import {
User,
Shield,
MessageSquare,
Lock,
CheckCircle,
FileCode,
Send,
Paperclip
} from 'lucide-react';
/**
* CollaborationStream 컴포넌트
* 역할별 색상 코딩이 적용된 이슈 타임라인을 표시합니다.
*/
export default function CollaborationStream() {
const events = [
{
type: 'report',
role: 'field',
user: '김현장 (파견원)',
time: '14:20',
content: '결제 모듈 처리 속도가 현저히 느려지면서 서버가 다운되었습니다. 현재 CPU와 메모리가 임계치에 도달한 상태입니다.',
badge: 'Field Observation'
},
{
type: 'event',
user: 'System',
time: '14:22',
content: '본사 전문가 [이전문 아키텍트]가 배정되었습니다.',
isEvent: true
},
{
type: 'internal',
role: 'hq',
user: '이전문 (아키텍트)',
time: '14:35',
content: '현장 로그를 보니 GC(Garbage Collection)가 빈번하게 발생하고 있습니다. 힙 메모리 설정값이 실제 물리 메모리 대비 낮게 설정된 것 같습니다.',
badge: 'Internal Note',
isInternal: true
},
{
type: 'official',
role: 'hq',
user: '이전문 (아키텍트)',
time: '14:45',
content: '김현장님, 현재 메모리 고갈로 인한 행(Hang) 현상으로 보입니다. 우선 JVM 옵션 중 -Xmx 값을 4G에서 8G로 상향 조정해 주시기 바랍니다. 관련 명령어를 첨부합니다.',
code: 'export JAVA_OPTS="-Xms8g -Xmx8g -XX:+UseG1GC"',
badge: 'Official Solution'
}
];
return (
<div className="flex flex-col h-full bg-slate-900/40 border border-slate-800 rounded-2xl shadow-xl overflow-hidden">
{/* Stream Header */}
<div className="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-900/80">
<div className="flex items-center space-x-2">
<MessageSquare className="w-4 h-4 text-indigo-400" />
<h3 className="text-xs font-bold text-slate-300 uppercase tracking-widest">Activity Stream</h3>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1.5 text-[10px] font-bold text-slate-500 uppercase">
<div className="w-2 h-2 rounded-full bg-indigo-500"></div>
<span>HQ</span>
</div>
<div className="flex items-center space-x-1.5 text-[10px] font-bold text-slate-500 uppercase">
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
<span>Field</span>
</div>
<div className="flex items-center space-x-1.5 text-[10px] font-bold text-slate-500 uppercase">
<div className="w-2 h-2 rounded-full bg-slate-500"></div>
<span>Internal</span>
</div>
</div>
</div>
{/* Stream Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
{events.map((ev, i) => (
<div key={i} className={`relative pl-8 ${ev.isInternal ? 'opacity-70' : ''}`}>
{/* Timeline Line */}
{i !== events.length - 1 && (
<div className="absolute left-[15px] top-8 bottom-[-32px] w-[2px] bg-slate-800"></div>
)}
{/* Timeline Node */}
<div className={`absolute left-0 top-0 w-8 h-8 rounded-full flex items-center justify-center z-10 border-2 shadow-lg ${
ev.isEvent ? 'bg-slate-800 border-slate-700' :
ev.role === 'hq' ? (ev.isInternal ? 'bg-slate-800 border-slate-700' : 'bg-indigo-600 border-indigo-400') :
'bg-emerald-600 border-emerald-400'
}`}>
{ev.isEvent ? <CheckCircle className="w-4 h-4 text-slate-400" /> :
ev.isInternal ? <Lock className="w-4 h-4 text-slate-400" /> :
<User className="w-4 h-4 text-white" />}
</div>
{/* Content Card */}
<div className={`rounded-2xl p-5 border shadow-sm ${
ev.isEvent ? 'bg-transparent border-transparent' :
ev.isInternal ? 'bg-slate-800/40 border-slate-700 text-slate-400' :
ev.role === 'hq' ? 'bg-indigo-900/20 border-indigo-500/20 text-slate-200' :
'bg-emerald-900/20 border-emerald-500/20 text-slate-200'
}`}>
{ev.isEvent ? (
<div className="text-sm font-medium text-slate-500 flex items-center">
<span className="mr-2">{ev.content}</span>
<span className="text-[10px] font-mono">{ev.time}</span>
</div>
) : (
<>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<span className="text-sm font-bold text-white">{ev.user}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wider ${
ev.isInternal ? 'bg-slate-700 text-slate-400' :
ev.role === 'hq' ? 'bg-indigo-500/20 text-indigo-400' :
'bg-emerald-500/20 text-emerald-400'
}`}>
{ev.badge}
</span>
</div>
<span className="text-[10px] font-mono text-slate-500">{ev.time}</span>
</div>
<p className="text-sm leading-relaxed mb-4">{ev.content}</p>
{ev.code && (
<div className="bg-slate-950 rounded-xl p-4 border border-slate-800 font-mono text-xs text-indigo-300 relative group">
<div className="absolute top-2 right-2 p-1.5 hover:bg-slate-800 rounded text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity">
<FileCode className="w-3.5 h-3.5" />
</div>
<code>{ev.code}</code>
</div>
)}
</>
)}
</div>
</div>
))}
</div>
{/* Stream Input */}
<div className="p-4 bg-slate-900/80 border-t border-slate-800 backdrop-blur-md">
<div className="max-w-4xl mx-auto flex items-end space-x-4">
<div className="flex-1 bg-slate-950 border border-slate-800 rounded-2xl p-3 focus-within:border-indigo-500/50 transition-colors shadow-inner">
<textarea
placeholder="답변을 입력하거나 파일을 드래그하세요..."
className="w-full bg-transparent text-sm text-slate-200 focus:outline-none resize-none min-h-[40px] max-h-[200px]"
rows={2}
></textarea>
<div className="flex items-center justify-between pt-2 mt-2 border-t border-slate-800/50">
<div className="flex items-center space-x-1">
<button className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-500 transition-colors">
<Paperclip className="w-4 h-4" />
</button>
<button className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-500 transition-colors">
<FileCode className="w-4 h-4" />
</button>
</div>
<div className="flex items-center space-x-2">
<label className="flex items-center space-x-2 cursor-pointer group">
<div className="w-4 h-4 rounded border border-slate-700 bg-slate-800 flex items-center justify-center group-hover:border-indigo-500 transition-colors">
<Lock className="w-2.5 h-2.5 text-slate-500" />
</div>
<span className="text-[10px] font-bold text-slate-500 uppercase group-hover:text-slate-300">Internal Note</span>
</label>
<button className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-xl text-xs font-bold shadow-lg transition-all flex items-center">
전송
<Send className="w-3 h-3 ml-2" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import React from 'react';
import {
Activity,
Cpu,
Database,
Wifi,
Terminal,
Filter,
Maximize2
} from 'lucide-react';
/**
* DiagnosticCockpit 컴포넌트
* 현장의 실시간 시스템 메트릭과 에러 로그를 시각화합니다.
*/
export default function DiagnosticCockpit() {
const metrics = [
{ label: 'CPU Usage', value: 84, color: 'text-orange-500', bg: 'bg-orange-500/10', bar: 'bg-orange-500', icon: Cpu },
{ label: 'Memory', value: 92, color: 'text-red-500', bg: 'bg-red-500/10', bar: 'bg-red-500', icon: Database },
{ label: 'Latency', value: 124, unit: 'ms', color: 'text-yellow-500', bg: 'bg-yellow-500/10', bar: 'bg-yellow-500', icon: Wifi },
];
const logs = [
{ time: '14:20:12', type: 'ERROR', message: 'java.lang.OutOfMemoryError: Java heap space' },
{ time: '14:20:15', type: 'WARN', message: 'GC overhead limit exceeded' },
{ time: '14:21:03', type: 'INFO', message: 'Connection pool established (32/100)' },
{ time: '14:22:10', type: 'ERROR', message: 'Fail to process transaction [ID: TX-9002]' },
{ time: '14:22:45', type: 'DEBUG', message: 'Snapshot thread started' },
];
return (
<div className="flex flex-col h-full gap-6">
{/* Metrics Widgets */}
<div className="grid grid-cols-1 gap-4">
{metrics.map((m, i) => (
<div key={i} className="bg-slate-900 border border-slate-800 rounded-2xl p-4 shadow-sm group hover:border-slate-700 transition-colors">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center space-x-2">
<div className={`p-1.5 rounded-lg ${m.bg}`}>
<m.icon className={`w-3.5 h-3.5 ${m.color}`} />
</div>
<span className="text-xs font-bold text-slate-400 uppercase tracking-tight">{m.label}</span>
</div>
<span className={`text-sm font-mono font-bold ${m.color}`}>
{m.value}{m.unit || '%'}
</span>
</div>
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full ${m.bar} transition-all duration-1000 ease-out`}
style={{ width: `${Math.min(m.value, 100)}%` }}
></div>
</div>
</div>
))}
</div>
{/* Log Viewer */}
<div className="flex-1 bg-slate-950 border border-slate-800 rounded-2xl flex flex-col overflow-hidden shadow-2xl">
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
<div className="flex items-center space-x-2">
<Terminal className="w-4 h-4 text-indigo-400" />
<h3 className="text-xs font-bold text-slate-300 uppercase tracking-wider">Field Diagnostic Logs</h3>
</div>
<div className="flex items-center space-x-2">
<button className="p-1.5 hover:bg-slate-800 rounded-md text-slate-500 transition-colors">
<Filter className="w-3.5 h-3.5" />
</button>
<button className="p-1.5 hover:bg-slate-800 rounded-md text-slate-500 transition-colors">
<Maximize2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 font-mono text-[11px] leading-relaxed space-y-2">
{logs.map((log, i) => (
<div key={i} className="flex items-start space-x-3 group hover:bg-white/5 p-1 rounded transition-colors cursor-pointer">
<span className="text-slate-600 flex-shrink-0">[{log.time}]</span>
<span className={`font-bold flex-shrink-0 w-12 ${
log.type === 'ERROR' ? 'text-red-500' :
log.type === 'WARN' ? 'text-yellow-500' :
log.type === 'INFO' ? 'text-blue-400' : 'text-slate-500'
}`}>
{log.type}
</span>
<span className="text-slate-300 break-all">{log.message}</span>
</div>
))}
<div className="h-4"></div>
<div className="flex items-center space-x-2 text-slate-600 italic">
<span className="animate-pulse">_</span>
<span>Tailing logs from site...</span>
</div>
</div>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
'use client';
import React from 'react';
import { AlertCircle, Clock, CheckCircle2, User } from 'lucide-react';
export interface TicketCardData {
ticketId: number;
title: string;
projectName?: string;
requesterName?: string;
assigneeName?: string | null;
priority: string;
category: string;
status: string;
dueDate?: string | null;
isImminent?: boolean;
}
const statusLabel: Record<string, { color: string; bg: string; label: string }> = {
OPEN: { color: 'text-status-open-text', bg: 'bg-status-open-bg', label: 'Open' },
ASSIGNED: { color: 'text-blue-600', bg: 'bg-blue-50', label: 'Assigned' },
IN_PROGRESS: { color: 'text-status-pending-text', bg: 'bg-status-pending-bg', label: 'In Progress' },
RESOLVED: { color: 'text-status-resolved-text', bg: 'bg-status-resolved-bg', label: 'Resolved' },
CLOSED: { color: 'text-status-closed-text', bg: 'bg-status-closed-bg', label: 'Closed' },
};
const priorityConfig: Record<string, { color: string; bg: string; label: string }> = {
CRITICAL: { color: 'text-status-critical-text', bg: 'bg-status-critical-bg', label: '긴급' },
HIGH: { color: 'text-status-pending-text', bg: 'bg-status-pending-bg', label: '높음' },
NORMAL: { color: 'text-status-open-text', bg: 'bg-status-open-bg', label: '보통' },
LOW: { color: 'text-text-muted', bg: 'bg-gray-100', label: '낮음' },
};
const categoryLabel: Record<string, string> = {
BUG: '버그',
FEATURE: '기능',
SUPPORT: '기술지원',
QA: 'QA',
};
export default function TicketCard({ ticket, onClick }: { ticket: TicketCardData; onClick?: () => void }) {
const pri = priorityConfig[ticket.priority] || priorityConfig.NORMAL;
const stat = statusLabel[ticket.status];
return (
<div
onClick={onClick}
className="bg-white rounded-xl border border-border p-4 cursor-pointer hover:shadow-soft hover:border-primary/30 transition-all group"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-1.5">
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded ${pri.bg} ${pri.color}`}>
{pri.label}
</span>
{stat && (
<span className={`text-[10px] font-medium px-2 py-0.5 rounded ${stat.bg} ${stat.color}`}>
{stat.label}
</span>
)}
</div>
<span className="text-[10px] font-medium text-text-muted bg-gray-50 px-2 py-0.5 rounded">
{categoryLabel[ticket.category] || ticket.category}
</span>
</div>
<h4 className="text-xs font-semibold text-text-main leading-snug mb-3 line-clamp-2 group-hover:text-primary transition-colors">
{ticket.title}
</h4>
{ticket.projectName && (
<p className="text-[10px] text-text-muted mb-2 truncate">{ticket.projectName}</p>
)}
<div className="flex items-center justify-between text-[10px] text-text-muted pt-2 border-t border-border/50">
<div className="flex items-center">
<User className="w-3 h-3 mr-1" />
<span className="truncate max-w-[80px]">{ticket.assigneeName || ticket.requesterName || '-'}</span>
</div>
{ticket.dueDate && (
<div className={`flex items-center ${ticket.isImminent ? 'text-status-critical-text font-semibold' : ''}`}>
{ticket.isImminent ? <AlertCircle className="w-3 h-3 mr-1" /> : <Clock className="w-3 h-3 mr-1" />}
<span>{new Date(ticket.dueDate).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}</span>
</div>
)}
</div>
</div>
);
}
export function KanbanColumn({ title, count, color, children }: { title: string; count: number; color: string; children: React.ReactNode }) {
return (
<div className="flex-1 min-w-[240px] max-w-[320px] flex flex-col">
<div className="flex items-center space-x-2 mb-4 px-1">
<div className={`w-2.5 h-2.5 rounded-full ${color}`} />
<h3 className="font-outfit font-semibold text-sm text-text-main">{title}</h3>
<span className="text-xs text-text-muted bg-gray-100 px-2 py-0.5 rounded-full font-medium">{count}</span>
</div>
<div className="flex-1 space-y-3 overflow-y-auto min-h-[200px] bg-gray-50/30 rounded-2xl p-3">
{count === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-text-muted">
<CheckCircle2 className="w-8 h-8 mb-2 opacity-30" />
<p className="text-xs">No tickets</p>
</div>
) : (
children
)}
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import React, { useState } from 'react';
import {
ClipboardList,
CheckCircle2,
Circle,
ArrowRight,
Lightbulb,
AlertTriangle
} from 'lucide-react';
/**
* SopRoadmap 컴포넌트
* 전문가가 제시하는 단계별 해결 절차와 진행률을 표시합니다.
*/
export default function SopRoadmap() {
const [steps, setSteps] = useState([
{ id: 1, title: 'JVM Heap 설정 확인', status: 'completed', description: '현재 설정된 -Xmx, -Xms 값을 서버 로그에서 확인' },
{ id: 2, title: 'JVM 옵션 상향 조정', status: 'in-progress', description: '기존 4G에서 8G로 변경 후 서비스 재기동' },
{ id: 3, title: '재기동 후 GC 로그 모니터링', status: 'pending', description: 'Full GC 발생 주기 및 처리 시간 확인' },
{ id: 4, title: '최종 안정성 검증', status: 'pending', description: '24시간 동안 메모리 누수 발생 여부 체크' },
]);
const completedCount = steps.filter(s => s.status === 'completed').length;
const progress = Math.round((completedCount / steps.length) * 100);
return (
<div className="flex flex-col gap-6">
{/* Progress Card */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-5 shadow-sm">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center space-x-2">
<ClipboardList className="w-4 h-4 text-indigo-400" />
<h3 className="text-xs font-bold text-slate-300 uppercase tracking-widest">SOP Roadmap</h3>
</div>
<span className="text-[10px] font-bold text-indigo-400 px-1.5 py-0.5 bg-indigo-500/10 rounded border border-indigo-500/20">
{progress}% Done
</span>
</div>
<div className="h-2 w-full bg-slate-800 rounded-full overflow-hidden mb-2">
<div
className="h-full bg-indigo-500 transition-all duration-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]"
style={{ width: `${progress}%` }}
></div>
</div>
<p className="text-[10px] text-slate-500 text-right font-medium">{completedCount} of {steps.length} steps completed</p>
</div>
{/* Step List */}
<div className="space-y-3">
{steps.map((step, i) => (
<div
key={step.id}
className={`bg-slate-900/60 border rounded-2xl p-4 transition-all ${
step.status === 'in-progress' ? 'border-indigo-500/50 bg-indigo-500/5 shadow-soft' :
step.status === 'completed' ? 'border-slate-800 opacity-60' : 'border-slate-800'
}`}
>
<div className="flex items-start space-x-3">
<div className="mt-0.5">
{step.status === 'completed' ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : step.status === 'in-progress' ? (
<div className="w-4 h-4 rounded-full border-2 border-indigo-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500 animate-ping"></div>
</div>
) : (
<Circle className="w-4 h-4 text-slate-700" />
)}
</div>
<div className="flex-1 min-w-0">
<h4 className={`text-sm font-bold mb-1 ${
step.status === 'completed' ? 'text-slate-400 line-through' :
step.status === 'in-progress' ? 'text-white' : 'text-slate-500'
}`}>
{step.title}
</h4>
{step.status !== 'completed' && (
<p className="text-xs text-slate-500 leading-relaxed mb-3">{step.description}</p>
)}
{step.status === 'in-progress' && (
<button className="flex items-center text-[10px] font-bold text-indigo-400 hover:text-indigo-300 transition-colors">
마무리 후 다음 단계로
<ArrowRight className="w-3 h-3 ml-1.5" />
</button>
)}
</div>
</div>
</div>
))}
</div>
{/* Expert Tips */}
<div className="bg-amber-900/10 border border-amber-500/20 rounded-2xl p-4">
<div className="flex items-center space-x-2 mb-2 text-amber-500">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="text-[10px] font-bold uppercase tracking-wider">Safety Reminder</span>
</div>
<p className="text-[11px] text-amber-200/60 leading-relaxed">
JVM 설정을 변경할 때는 서비스 가용성에 영향을 줄 수 있으므로, 반드시 **운영 시간 이후(PM 10:00)**에 작업을 진행해 주십시오.
</p>
</div>
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
'use client';
import React, { useState } from 'react';
import {
X,
Plus,
ArrowUpRight,
ArrowDownLeft,
Wrench,
Camera,
Upload,
CheckCircle2,
AlertCircle
} from 'lucide-react';
interface AssetActionModalProps {
isOpen: boolean;
onClose: () => void;
asset?: any;
}
/**
* IT 자산 통합 업무 처리 모달
* 등록, 반입, 반출, 정비를 한 곳에서 처리합니다.
*/
export default function AssetActionModal({ isOpen, onClose, asset }: AssetActionModalProps) {
const [activeTab, setActiveTab] = useState<'checkout' | 'checkin' | 'maintenance' | 'register'>('checkout');
if (!isOpen) return null;
const tabs = [
{ id: 'checkout', label: '반출 (배정)', icon: ArrowUpRight, color: 'text-orange-500 bg-orange-50' },
{ id: 'checkin', label: '반입 (회수)', icon: ArrowDownLeft, color: 'text-blue-500 bg-blue-50' },
{ id: 'maintenance', label: '정비 (수리)', icon: Wrench, color: 'text-purple-500 bg-purple-50' },
{ id: 'register', label: '신규 등록', icon: Plus, color: 'text-emerald-500 bg-emerald-50' },
];
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
{/* Backdrop */}
<div className="absolute inset-0 bg-text-main/60 backdrop-blur-sm" onClick={onClose}></div>
{/* Modal Content */}
<div className="relative bg-surface w-full max-w-2xl rounded-[32px] shadow-modal overflow-hidden animate-in zoom-in-95 duration-200">
{/* Modal Header */}
<div className="px-8 py-6 border-b border-border flex items-center justify-between">
<div>
<h3 className="text-xl font-bold text-text-main">장비 업무 처리</h3>
<p className="text-xs text-text-muted mt-1">자산의 상태 변경 및 이동 이력을 기록합니다.</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-5 h-5 text-text-muted" />
</button>
</div>
{/* Tabs */}
<div className="flex px-4 py-2 bg-gray-50/50 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex-1 flex items-center justify-center space-x-2 py-3 px-4 rounded-2xl text-xs font-bold transition-all ${
activeTab === tab.id
? 'bg-surface text-text-main shadow-sm ring-1 ring-border/50'
: 'text-text-muted hover:text-text-main'
}`}
>
<tab.icon className={`w-3.5 h-3.5 ${activeTab === tab.id ? tab.color.split(' ')[0] : ''}`} />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Content Area */}
<div className="p-8 max-h-[60vh] overflow-y-auto">
{/* Selected Asset Info (Summary) */}
{asset && (
<div className="mb-8 p-4 bg-gray-50 rounded-2xl border border-border flex items-center justify-between">
<div className="flex items-center">
<div className="w-10 h-10 rounded-xl bg-surface border border-border flex items-center justify-center mr-3 shadow-card">
<Camera className="w-5 h-5 text-text-muted" />
</div>
<div>
<p className="text-[10px] font-bold text-text-muted uppercase tracking-wider">{asset.id}</p>
<p className="text-sm font-bold text-text-main">{asset.model}</p>
</div>
</div>
<div className="text-right">
<p className="text-[10px] font-bold text-text-muted uppercase">현재 상태</p>
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
{asset.status}
</span>
</div>
</div>
)}
{/* Form Content Based on Tab */}
<div className="grid grid-cols-2 gap-6">
{activeTab === 'checkout' && (
<>
<div className="col-span-2 space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">대상 프로젝트</label>
<select className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all">
<option>삼성페이 고도화 (SI)</option>
<option>카카오 유지보수</option>
<option>본사 R&D 센터</option>
</select>
</div>
<div className="col-span-2 space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">사용자 (파견원) 검색</label>
<div className="relative">
<input
type="text"
placeholder="이름을 입력하여 검색..."
className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
<div className="absolute z-10 mt-1 w-full bg-surface rounded-xl border border-border shadow-float max-h-40 overflow-y-auto hidden">
<div className="p-3 hover:bg-gray-50 cursor-pointer border-b border-border">
<p className="text-sm font-medium text-text-main">김코딩</p>
<p className="text-[10px] text-text-muted">BACKEND · 삼성페이 SI</p>
</div>
<div className="p-3 hover:bg-gray-50 cursor-pointer border-b border-border">
<p className="text-sm font-medium text-text-main">이현장</p>
<p className="text-[10px] text-text-muted">DBA · LG CNS SM</p>
</div>
</div>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">지급 상태 코멘트</label>
<input type="text" placeholder="예: 미개봉 신품" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">반출 예정일</label>
<input type="date" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
</div>
</>
)}
{activeTab === 'checkin' && (
<>
<div className="col-span-2 space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">반입 사유</label>
<select className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all">
<option>프로젝트 종료</option>
<option>장비 교체 (업그레이드)</option>
<option>사용자 퇴사/인사이동</option>
<option>기타</option>
</select>
</div>
<div className="col-span-2 space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">반납 상태 코멘트</label>
<input type="text" placeholder="예: C상판 커피 침수 흔적 - 수리 필요" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
</div>
</>
)}
{activeTab === 'maintenance' && (
<>
<div className="col-span-2 space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">증상 및 정비 사유</label>
<textarea rows={3} placeholder="고장 증상을 상세히 적어주세요." className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all resize-none"></textarea>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">정비 업체</label>
<input type="text" placeholder="업체명 입력" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">예상 완료일</label>
<input type="date" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
</div>
</>
)}
{activeTab === 'register' && (
<>
<div className="col-span-2 space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">모델명</label>
<input type="text" placeholder='예: MacBook Pro 16"' className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">자산 유형</label>
<select className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all">
<option>노트북</option>
<option>데스크탑</option>
<option>서버</option>
<option>모니터</option>
<option>기타</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-muted ml-1">시리얼 번호</label>
<input type="text" placeholder="S/N 입력" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
</div>
</>
)}
{/* Simple Photo Record Area (단순 기록용 사진 첨부) */}
<div className="col-span-2 mt-4">
<label className="text-xs font-bold text-text-muted ml-1 mb-1.5 block">상태 기록 사진 (단순 참고용)</label>
<div className="flex items-center space-x-4">
<div className="flex-1 border-2 border-dashed border-border rounded-2xl p-6 flex flex-col items-center justify-center hover:bg-gray-50 hover:border-primary/30 transition-all cursor-pointer">
<Upload className="w-6 h-6 text-gray-300 mb-2" />
<p className="text-[11px] font-bold text-text-muted">사진을 드래그하거나 클릭하여 첨부</p>
</div>
<div className="w-20 h-20 rounded-2xl bg-gray-100 border border-border flex items-center justify-center text-gray-300 relative group overflow-hidden">
<Camera className="w-6 h-6" />
<div className="absolute inset-0 bg-text-main/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] font-bold text-white">촬영하기</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Modal Footer */}
<div className="px-8 py-6 bg-gray-50 border-t border-border flex justify-end space-x-3">
<button onClick={onClose} className="px-6 py-2.5 rounded-xl text-sm font-bold text-text-muted hover:bg-gray-200 transition-all">
취소
</button>
<button className="bg-primary text-white px-8 py-2.5 rounded-xl text-sm font-bold shadow-float hover:bg-primary-hover transition-all flex items-center">
<CheckCircle2 className="w-4 h-4 mr-2" />
기록 완료
</button>
</div>
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
'use client';
import React, { useState } from 'react';
import {
Laptop,
Monitor,
Smartphone,
Server,
User,
Calendar,
MoreVertical,
ExternalLink,
History,
ArrowUpRight,
ArrowDownLeft,
Wrench,
ChevronRight,
Image as ImageIcon,
X,
QrCode
} from 'lucide-react';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import AssetLabel from './AssetLabel';
import Link from 'next/link';
/**
* 자산 인벤토리 리스트 컴포넌트
* IT 자산의 상세 목록을 출력하며 개별 액션을 제공합니다.
*/
export default function AssetInventory({ equipment, onRefresh, currentPage, totalPages, onPageChange }: { equipment: import('@/types').Equipment[]; onRefresh: () => void; currentPage?: number; totalPages?: number; onPageChange?: (page: number) => void }) {
const [labelAsset, setLabelAsset] = useState<import('@/types').Equipment | null>(null);
const { codes: equipmentStatusCodes, loading: statusCodesLoading } = useCommonCodes('EQUIPMENT_STATUS');
const statusLabel = statusCodesLoading ? {} : Object.fromEntries(equipmentStatusCodes.map((c) => [c.codeValue, c.codeName]));
const getTypeIcon = (type: string) => {
switch (type) {
case 'LAPTOP': return <Laptop className="w-4 h-4 text-indigo-500" />;
case 'SERVER': return <Server className="w-4 h-4 text-blue-500" />;
case 'MONITOR': return <Monitor className="w-4 h-4 text-emerald-500" />;
default: return <Laptop className="w-4 h-4 text-slate-500" />;
}
};
const getStatusStyle = (status: string) => {
switch (status) {
case 'IN_USE': return 'bg-green-50 text-green-600 border-green-100';
case 'AVAILABLE': return 'bg-blue-50 text-blue-600 border-blue-100';
case 'REPAIRING': return 'bg-orange-50 text-orange-600 border-orange-100';
case 'RETIRED': return 'bg-gray-100 text-gray-500 border-gray-200';
default: return 'bg-gray-50 text-gray-600 border-gray-100';
}
};
const getStatusDot = (status: string) => {
switch (status) {
case 'IN_USE': return 'bg-green-500';
case 'AVAILABLE': return 'bg-blue-500';
case 'REPAIRING': return 'bg-orange-500';
default: return 'bg-gray-400';
}
};
return (
<div>
{/* Desktop table */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-left border-collapse min-w-[800px]">
<thead>
<tr className="bg-slate-50/50 border-b border-slate-100 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
<th className="px-4 sm:px-8 py-4 font-bold w-12">Photo</th>
<th className="px-4 sm:px-8 py-4 font-bold">Asset ID & Type</th>
<th className="px-4 sm:px-8 py-4 font-bold">Model & Serial</th>
<th className="px-4 sm:px-8 py-4 font-bold">Project / User</th>
<th className="px-4 sm:px-8 py-4 font-bold">Status</th>
<th className="px-4 sm:px-8 py-4 font-bold text-right">Context Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{equipment.map((asset) => (
<tr key={asset.equipmentId} className="hover:bg-slate-50/30 transition-colors group">
<td className="px-4 sm:px-8 py-5">
{(() => {
let imgs: { url: string }[] = [];
if (asset.images) {
try { imgs = typeof asset.images === 'string' ? JSON.parse(asset.images) : asset.images; } catch {}
}
return imgs.length > 0 ? (
<div className="w-10 h-10 rounded-xl overflow-hidden border border-slate-200">
<img src={imgs[0].url} alt="" className="w-full h-full object-cover" />
</div>
) : (
<div className="w-10 h-10 rounded-xl bg-slate-100 border border-slate-200 flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-slate-300" />
</div>
);
})()}
</td>
<td className="px-4 sm:px-8 py-5">
<div className="flex items-center">
<div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center shadow-sm mr-4 group-hover:scale-110 transition-transform duration-300 flex-shrink-0">
{getTypeIcon(asset.equipmentType)}
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-slate-900 truncate">{asset.assetCode}</p>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-tighter">{asset.equipmentType}</p>
</div>
</div>
</td>
<td className="px-4 sm:px-8 py-5">
<p className="text-sm font-bold text-slate-900 truncate max-w-[150px] sm:max-w-none">{asset.modelName}</p>
<p className="text-[11px] font-mono text-slate-400 truncate">{asset.serialNumber}</p>
</td>
<td className="px-4 sm:px-8 py-5">
<p className="text-xs font-bold text-slate-700 truncate">{asset.manufacturer}</p>
<p className="text-[11px] font-bold text-slate-400 whitespace-nowrap">Purchased: {asset.purchaseDate}</p>
</td>
<td className="px-4 sm:px-8 py-5">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-[10px] font-bold border ${getStatusStyle(asset.status)}`}>
<div className={`w-1.5 h-1.5 rounded-full mr-2 ${getStatusDot(asset.status)}`}></div>
{statusLabel[asset.status as keyof typeof statusLabel] || asset.status}
</span>
</td>
<td className="px-4 sm:px-8 py-5 text-right">
<div className="flex items-center justify-end space-x-1 opacity-0 group-hover:opacity-100 transition-all">
{asset.status === 'AVAILABLE' ? (
<Link href="/itam/checkout" className="p-2 bg-orange-500 border border-orange-600 rounded-lg hover:bg-orange-600 text-white transition-all shadow-lg shadow-orange-500/20" title="장비 배정">
<ArrowUpRight className="w-4 h-4" />
</Link>
) : asset.status === 'IN_USE' ? (
<Link href="/itam/checkin" className="p-2 bg-blue-600 border border-blue-700 rounded-lg hover:bg-blue-700 text-white transition-all shadow-lg shadow-blue-500/20" title="장비 회수">
<ArrowDownLeft className="w-4 h-4" />
</Link>
) : null}
<button onClick={() => setLabelAsset(asset)} className="p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-400 hover:text-primary transition-all" title="자산 라벨">
<QrCode className="w-4 h-4" />
</button>
<Link href={`/itam/history/${asset.equipmentId}`} className="p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-400 hover:text-blue-500 transition-all" title="이력 보기">
<History className="w-4 h-4" />
</Link>
<button onClick={() => alert(`Maintenance request for: ${asset.modelName}\nAsset: ${asset.assetCode}\n\nThis feature will be available soon.`)} className="p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-400 hover:text-orange-500 transition-all" title="정비 의뢰">
<Wrench className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden space-y-3">
{equipment.map((asset) => (
<div key={asset.equipmentId} className="bg-white rounded-2xl border border-slate-200 p-4 space-y-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{(() => {
let imgs: {url:string}[] = [];
try { imgs = typeof asset.images === 'string' ? JSON.parse(asset.images) : (asset.images || []); } catch {}
return imgs.length > 0 ? (
<div className="w-10 h-10 rounded-xl overflow-hidden border"><img src={imgs[0].url} alt="" className="w-full h-full object-cover"/></div>
) : (
<div className="w-10 h-10 rounded-xl bg-slate-100 border flex items-center justify-center">{getTypeIcon(asset.equipmentType)}</div>
);
})()}
<div>
<p className="text-sm font-bold text-slate-900">{asset.modelName}</p>
<p className="text-[10px] text-slate-400 font-mono">{asset.serialNumber}</p>
</div>
</div>
<span className={`text-[10px] font-bold px-2.5 py-1 rounded-full border ${getStatusStyle(asset.status)}`}>
{statusLabel[asset.status as keyof typeof statusLabel] || asset.status}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-slate-500">
<div><span className="text-slate-700 font-medium">Code:</span> {asset.assetCode}</div>
<div><span className="text-slate-700 font-medium">Type:</span> {asset.equipmentType}</div>
<div><span className="text-slate-700 font-medium">Maker:</span> {asset.manufacturer}</div>
<div><span className="text-slate-700 font-medium">Date:</span> {asset.purchaseDate}</div>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
<button onClick={() => setLabelAsset(asset)} className="flex-1 py-2 bg-slate-100 rounded-xl text-xs font-medium text-slate-600 flex items-center justify-center gap-1"><QrCode className="w-3.5 h-3.5" /> Label</button>
{asset.status === 'AVAILABLE' ? (
<Link href="/itam/checkout" className="flex-1 py-2 bg-orange-500 text-white rounded-xl text-xs font-medium text-center">Assign</Link>
) : null}
</div>
</div>
))}
</div>
{/* Pagination - shows on both mobile and desktop */}
<div className="px-4 sm:px-8 py-5 bg-slate-50/30 border-t border-slate-100 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<p className="text-[11px] font-bold text-slate-400">Showing {equipment.length} assets</p>
{totalPages !== undefined && totalPages > 1 && (
<div className="flex space-x-1">
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i).map((p) => (
<button key={p} onClick={() => onPageChange?.(p)}
className={`w-8 h-8 rounded-xl text-[10px] font-bold transition-all flex items-center justify-center ${
(currentPage ?? 0) === p ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'text-slate-400 hover:bg-white hover:border-slate-200 border border-transparent'
}`}
>
{p + 1}
</button>
))}
</div>
)}
</div>
{labelAsset && (
<AssetLabel
assetCode={labelAsset.assetCode}
modelName={labelAsset.modelName}
serialNumber={labelAsset.serialNumber}
manufacturer={labelAsset.manufacturer}
purchaseDate={labelAsset.purchaseDate}
onClose={() => setLabelAsset(null)}
/>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
'use client';
import React, { useEffect, useRef } from 'react';
import QRCode from 'qrcode';
interface Props {
assetCode: string;
modelName: string;
serialNumber: string;
manufacturer: string;
purchaseDate: string;
onClose?: () => void;
}
export default function AssetLabel({ assetCode, modelName, serialNumber, manufacturer, purchaseDate, onClose }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (canvasRef.current) {
QRCode.toCanvas(canvasRef.current, assetCode, {
width: 160,
margin: 1,
color: { dark: '#1e293b', light: '#ffffff' },
});
}
}, [assetCode]);
const handlePrint = async () => {
const w = window.open('', '_blank');
if (!w) return;
// Generate QR as data URL
const qrCanvas = document.createElement('canvas');
qrCanvas.width = 160;
qrCanvas.height = 160;
await QRCode.toCanvas(qrCanvas, assetCode, { width: 160, margin: 1 });
const qrDataUrl = qrCanvas.toDataURL();
w.document.write(`
<html><head><style>
body { margin: 0; padding: 20px; font-family: Inter, sans-serif; }
.label { width: 280px; border: 1px solid #ccc; padding: 16px; border-radius: 8px; margin: 0 auto; }
.label img { display: block; margin: 0 auto 12px; width: 160px; height: 160px; }
.code { font-size: 18px; font-weight: 700; text-align: center; letter-spacing: 1px; margin-bottom: 8px; }
.info { font-size: 11px; color: #555; line-height: 1.6; }
.info span { color: #1e293b; font-weight: 600; }
.title { text-align: center; font-size: 10px; color: #94a3b8; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 2px; }
</style></head><body>
<div class="label">
<div class="title">SR System</div>
<img src="${qrDataUrl}" alt="QR" />
<div class="code">${assetCode}</div>
<div class="info">
Model: <span>${modelName}</span><br/>
S/N: <span>${serialNumber}</span><br/>
Maker: <span>${manufacturer}</span><br/>
Date: <span>${purchaseDate}</span>
</div>
</div>
<script>setTimeout(() => window.print(), 200);</script>
</body></html>
`);
w.document.close();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl p-6 max-w-xs mx-4" onClick={(e) => e.stopPropagation()}>
<div className="text-center mb-2">
<p className="text-[10px] text-slate-400 uppercase tracking-widest font-semibold">SR System · Asset Label</p>
</div>
<canvas ref={canvasRef} className="mx-auto mb-3" />
<p className="text-lg font-bold text-center tracking-wider text-slate-900">{assetCode}</p>
<div className="mt-3 text-xs text-slate-500 space-y-0.5">
<p><span className="text-slate-700 font-medium">Model:</span> {modelName}</p>
<p><span className="text-slate-700 font-medium">S/N:</span> {serialNumber}</p>
<p><span className="text-slate-700 font-medium">Maker:</span> {manufacturer}</p>
<p><span className="text-slate-700 font-medium">Date:</span> {purchaseDate}</p>
</div>
<div className="flex gap-2 mt-5">
<button onClick={onClose} className="flex-1 px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:bg-slate-100 transition-all">Close</button>
<button onClick={handlePrint} className="flex-1 px-4 py-2 rounded-xl text-sm font-medium bg-slate-900 text-white hover:bg-slate-800 transition-all">Print Label</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import React from 'react';
import { Box, CheckCircle2, Wrench, History, TrendingUp } from 'lucide-react';
export default function AssetStatusOverview({
summary,
filter,
onFilterChange,
}: {
summary: Record<string, number>;
filter: string;
onFilterChange: (f: string) => void;
}) {
const stats = [
{ label: 'Total Assets', value: summary.ALL || 0, icon: Box, color: 'text-primary bg-indigo-50', key: 'ALL' },
{ label: 'In Use', value: summary.IN_USE || 0, icon: CheckCircle2, color: 'text-green-600 bg-green-50', key: 'IN_USE' },
{ label: 'Available', value: summary.AVAILABLE || 0, icon: History, color: 'text-blue-600 bg-blue-50', key: 'AVAILABLE' },
{ label: 'Under Repair', value: summary.REPAIRING || 0, icon: Wrench, color: 'text-orange-600 bg-orange-50', key: 'REPAIRING' },
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat) => (
<button
key={stat.key}
onClick={() => onFilterChange(stat.key === 'ALL' ? 'ALL' : stat.key)}
className={`bg-surface p-6 rounded-3xl border transition-all text-left relative overflow-hidden group hover:shadow-soft ${
filter === stat.key ? 'border-primary ring-2 ring-primary/20' : 'border-border'
}`}
>
<div className="relative z-10">
<div className="flex items-center justify-between mb-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${stat.color}`}>
<stat.icon className="w-5 h-5" />
</div>
</div>
<p className="text-sm font-medium text-text-muted mb-1">{stat.label}</p>
<h3 className="font-outfit text-2xl font-bold text-text-main tracking-tight">{stat.value}</h3>
</div>
</button>
))}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import React from 'react';
import {
FileText,
ShieldCheck,
Calendar,
User,
MapPin,
ArrowUpRight,
ArrowDownLeft,
PenTool
} from 'lucide-react';
interface AssetVoucherCardProps {
type: 'CHECK_OUT' | 'CHECK_IN';
id: string;
date: string;
project: string;
user: string;
assets: any[];
signature?: string;
}
/**
* 디지털 전표(Voucher) UI 컴포넌트
* 반입/반출 증빙 자료를 시각화합니다.
*/
export default function AssetVoucherCard({
type,
id,
date,
project,
user,
assets,
signature
}: AssetVoucherCardProps) {
const isCheckout = type === 'CHECK_OUT';
return (
<div className="bg-surface border border-border rounded-[32px] shadow-modal overflow-hidden max-w-2xl mx-auto">
{/* Voucher Header */}
<div className={`p-8 ${isCheckout ? 'bg-orange-500' : 'bg-primary'} text-white`}>
<div className="flex justify-between items-start mb-6">
<div className="bg-white/20 p-3 rounded-2xl backdrop-blur-md">
<FileText className="w-8 h-8 text-white" />
</div>
<div className="text-right">
<p className="text-[10px] font-bold uppercase tracking-widest opacity-70">Digital Voucher</p>
<h3 className="text-2xl font-bold tracking-tight">{id}</h3>
</div>
</div>
<div className="flex items-center space-x-2">
<div className={`px-3 py-1 rounded-full text-[10px] font-bold bg-white ${isCheckout ? 'text-orange-500' : 'text-primary'}`}>
{isCheckout ? 'CHECK-OUT (반출/배정)' : 'CHECK-IN (반입/회수)'}
</div>
<span className="text-xs opacity-70 flex items-center">
<Calendar className="w-3 h-3 mr-1" />
{date}
</span>
</div>
</div>
{/* Voucher Body */}
<div className="p-8 space-y-8">
{/* Info Grid */}
<div className="grid grid-cols-2 gap-8 pb-8 border-b border-border">
<div className="space-y-1">
<p className="text-[10px] font-bold text-text-muted uppercase tracking-wider">대상 프로젝트</p>
<p className="text-sm font-bold text-text-main">{project}</p>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-text-muted uppercase tracking-wider">책임 사용자</p>
<p className="text-sm font-bold text-text-main">{user}</p>
</div>
</div>
{/* Asset List */}
<div>
<p className="text-[10px] font-bold text-text-muted uppercase tracking-wider mb-4">대상 자산 목록 ({assets.length}건)</p>
<div className="space-y-3">
{assets.map((asset, i) => (
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-2xl border border-border">
<div className="flex items-center">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center mr-3 ${isCheckout ? 'bg-orange-100' : 'bg-blue-100'}`}>
{isCheckout ? <ArrowUpRight className="w-4 h-4 text-orange-600" /> : <ArrowDownLeft className="w-4 h-4 text-blue-600" />}
</div>
<div>
<p className="text-xs font-bold text-text-main">{asset.model}</p>
<p className="text-[10px] font-mono text-text-muted">{asset.sn}</p>
</div>
</div>
<div className="text-right">
<span className="text-[10px] font-bold text-text-muted bg-surface px-2 py-0.5 rounded border border-border uppercase">
{asset.status || 'Normal'}
</span>
</div>
</div>
))}
</div>
</div>
{/* Declaration & Signature */}
<div className="pt-8 border-t border-border flex items-end justify-between">
<div className="max-w-[300px]">
<div className="flex items-center text-emerald-600 mb-2">
<ShieldCheck className="w-4 h-4 mr-1.5" />
<span className="text-[11px] font-bold">인수/반납 확인 완료</span>
</div>
<p className="text-[10px] text-text-muted leading-relaxed italic">
본 전표에 기재된 장비의 {isCheckout ? '인수' : '반납'}를 확인하며, {isCheckout ? '파손 및 분실 시 회사 규정에 따른 책임' : '재고 복귀'}를 승인합니다.
</p>
</div>
<div className="text-center">
<div className="w-32 h-16 bg-gray-50 rounded-xl border border-dashed border-border flex items-center justify-center mb-1 relative">
{signature ? (
<span className="font-cursive text-xl text-text-main">{signature}</span>
) : (
<PenTool className="w-5 h-5 text-gray-200" />
)}
<div className="absolute bottom-1 right-2 text-[8px] text-gray-300 font-bold uppercase">Signature</div>
</div>
<p className="text-[10px] font-bold text-text-muted uppercase">{user}</p>
</div>
</div>
</div>
{/* Footer Branding */}
<div className="px-8 py-4 bg-gray-50 border-t border-border flex items-center justify-between">
<p className="text-[10px] font-bold text-gray-300 uppercase tracking-widest">SR-SYSTEM ITAM INFRA</p>
<div className="flex space-x-4">
<button className="text-[10px] font-bold text-text-muted hover:text-text-main transition-colors">Print PDF</button>
<button className="text-[10px] font-bold text-text-muted hover:text-text-main transition-colors">Share Link</button>
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
'use client';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Html5Qrcode } from 'html5-qrcode';
import { X, Camera, AlertCircle, ZoomIn, ZoomOut } from 'lucide-react';
interface BarcodeScannerProps {
onScan: (value: string) => void;
onClose: () => void;
}
export default function BarcodeScanner({ onScan, onClose }: BarcodeScannerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState('');
const [zoom, setZoom] = useState(1);
const [zoomMax, setZoomMax] = useState(3);
const [supportsZoom, setSupportsZoom] = useState(false);
const initializedRef = useRef(false);
const scannerRef = useRef<Html5Qrcode | null>(null);
const trackRef = useRef<MediaStreamTrack | null>(null);
const applyZoom = useCallback(async (level: number, video?: HTMLVideoElement | null) => {
const v = video || (document.querySelector('#sr-barcode-scanner video') as HTMLVideoElement | null);
if (v && v.srcObject) {
if (trackRef.current && supportsZoom) {
try { await trackRef.current.applyConstraints({ advanced: [{ zoom: level }] as any }); } catch { }
}
(v as HTMLElement).style.transform = `scale(${level})`;
}
}, [supportsZoom]);
useEffect(() => {
// StrictMode guard: skip second effect run
if (initializedRef.current) return;
initializedRef.current = true;
const el = containerRef.current;
if (!el) return;
const id = 'sr-barcode-scanner';
el.id = id;
el.innerHTML = '';
let cancelled = false;
const s = new Html5Qrcode(id);
scannerRef.current = s;
const startWithFacing = async (facingMode: 'environment' | 'user') => {
await s.start(
{ facingMode },
{ fps: 30 },
(decodedText) => {
if (cancelled) return;
try { s.stop(); } catch { }
onScan(decodedText);
},
() => {}
);
// Set up zoom after camera starts
setTimeout(() => {
if (cancelled) return;
const video = el.querySelector('video');
if (video && video.srcObject) {
(video as HTMLElement).style.transition = 'transform 0.2s';
(video as HTMLElement).style.transformOrigin = 'center center';
const track = (video.srcObject as MediaStream).getVideoTracks()[0];
if (track) {
trackRef.current = track;
const caps = track.getCapabilities() as any;
if (caps?.zoom) { setSupportsZoom(true); setZoomMax(caps.zoom.max || 3); }
}
}
}, 300);
};
startWithFacing('environment').catch(() => {
return startWithFacing('user');
}).catch((err: unknown) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : String(err);
const isIOS = navigator.userAgent.includes('iPhone') || navigator.userAgent.includes('iPad');
if (msg.includes('NotAllowed') || msg.includes('Permission')) {
setError(isIOS ? 'Camera denied. Settings → Safari → Camera → Allow.' : 'Camera permission denied.');
} else if (msg.includes('NotFound')) {
setError('No camera found on this device.');
} else {
setError(msg);
}
});
return () => {
cancelled = true;
try { s.stop(); } catch { }
};
}, []);
const handleZoomIn = () => {
const newZoom = Math.min(zoom + 0.5, zoomMax);
setZoom(newZoom);
applyZoom(newZoom);
};
const handleZoomOut = () => {
const newZoom = Math.max(zoom - 0.5, 0.5);
setZoom(newZoom);
applyZoom(newZoom);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl overflow-hidden w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200">
<div className="flex items-center gap-2">
<Camera className="w-4 h-5 text-primary" />
<span className="text-sm font-bold text-slate-900">Scan Barcode</span>
</div>
<button onClick={onClose} className="p-1 hover:bg-slate-100 rounded-lg transition-colors">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
{error ? (
<div className="p-6 text-center space-y-4">
<div className="w-12 h-12 mx-auto rounded-full bg-red-50 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
<p className="text-sm text-slate-600">{error}</p>
<button onClick={onClose} className="px-6 py-2.5 bg-primary text-white rounded-xl text-sm font-medium">Close</button>
</div>
) : (
<>
<div className="relative w-full aspect-[4/3] overflow-hidden">
<div ref={containerRef} className="absolute inset-0 bg-black" />
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 rounded-full px-2 py-1.5 z-10">
<button onClick={handleZoomOut} className="p-1 text-white/70 hover:text-white" type="button"><ZoomOut className="w-4 h-4" /></button>
<span className="text-[10px] text-white font-mono min-w-[24px] text-center">{zoom.toFixed(1)}x</span>
<button onClick={handleZoomIn} className="p-1 text-white/70 hover:text-white" type="button"><ZoomIn className="w-4 h-4" /></button>
</div>
</div>
<p className="text-[11px] text-slate-500 text-center py-3 px-4">Position the barcode or QR code within the scanning area</p>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,300 @@
'use client';
import React, { useState } from 'react';
import {
Download,
Upload,
FileSpreadsheet,
AlertTriangle,
CheckCircle2,
RefreshCcw,
Zap
} from 'lucide-react';
import equipmentService from '@/services/equipmentService';
import type { Equipment, EquipmentType } from '@/types';
interface ParsedItem {
row: number;
data: Partial<Equipment>;
error?: string;
}
interface ValidationResult {
row: number;
status: 'Error' | 'Warning' | 'Success';
field: string;
value: string;
message: string;
}
/**
* 대량 등록 (엑셀) 뷰 컴포넌트
* 엑셀 업로드 및 데이터 유효성 검증 프로세스를 제공합니다.
*/
export default function BatchRegistrationView() {
const [isUploaded, setIsUploaded] = useState(false);
const [inputText, setInputText] = useState('');
const [parsedItems, setParsedItems] = useState<ParsedItem[]>([]);
const [validationResults, setValidationResults] = useState<ValidationResult[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [successCount, setSuccessCount] = useState(0);
const [errorCount, setErrorCount] = useState(0);
const typeMapping: Record<string, EquipmentType> = {
'LAPTOP': 'LAPTOP',
'MONITOR': 'MONITOR',
'TEST_DEVICE': 'TEST_DEVICE',
'ACCESSORY': 'ACCESSORY',
};
const parseInput = () => {
const lines = inputText.trim().split('\n');
const items: ParsedItem[] = [];
const errors: ValidationResult[] = [];
lines.forEach((line, index) => {
const row = index + 1;
const parts = line.split(',').map(p => p.trim());
if (parts.length < 7) {
errors.push({
row,
status: 'Error',
field: 'Format',
value: line,
message: '필수 항목이 부족합니다 (7 개 항목 필요)',
});
items.push({ row, data: {}, error: 'Invalid format' });
return;
}
const [equipmentType, manufacturer, modelName, serialNumber, assetCode, location, purchaseDate] = parts;
if (!typeMapping[equipmentType.toUpperCase()]) {
errors.push({
row,
status: 'Error',
field: 'EquipmentType',
value: equipmentType,
message: '허용되지 않는 장비 유형입니다 (LAPTOP, MONITOR, TEST_DEVICE, ACCESSORY)',
});
items.push({ row, data: {}, error: 'Invalid type' });
return;
}
items.push({
row,
data: {
equipmentType: typeMapping[equipmentType.toUpperCase()],
manufacturer,
modelName,
serialNumber,
assetCode,
specifications: { location },
status: 'AVAILABLE',
purchaseDate,
warrantyExpDate: '',
},
});
if (errors.length === 0) {
errors.push({
row,
status: 'Success',
field: 'All',
value: '-',
message: '정상',
});
}
});
setParsedItems(items);
setValidationResults(errors);
setIsUploaded(true);
};
const handleBatchRegister = async () => {
setIsProcessing(true);
setSuccessCount(0);
setErrorCount(0);
const validItems = parsedItems.filter(item => !item.error);
for (const item of validItems) {
try {
await equipmentService.create(item.data);
setSuccessCount(prev => prev + 1);
} catch (error: any) {
setErrorCount(prev => prev + 1);
setValidationResults(prev => prev.map(res =>
res.row === item.row
? { ...res, status: 'Error', message: error.response?.data?.message || '등록 실패' }
: res
));
}
}
setIsProcessing(false);
};
const handleReset = () => {
setIsUploaded(false);
setInputText('');
setParsedItems([]);
setValidationResults([]);
setSuccessCount(0);
setErrorCount(0);
};
return (
<div className="space-y-8">
{/* Step 1: Template & Upload */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Template Card */}
<div className="bg-white p-8 rounded-[32px] border border-slate-200 shadow-sm flex flex-col justify-between">
<div>
<div className="w-12 h-12 rounded-2xl bg-emerald-50 flex items-center justify-center mb-6">
<Download className="w-6 h-6 text-emerald-500" />
</div>
<h3 className="text-lg font-bold text-slate-900 mb-2">1. 템플릿 양식 다운로드</h3>
<p className="text-xs text-slate-500 leading-relaxed">
정해진 엑셀 양식에 맞춰 데이터를 작성해야 오류 없이 일괄 등록이 가능합니다. 필수 입력 항목인 [모델명, 자산유형, 시리얼] 을 반드시 포함해 주세요.
</p>
</div>
<button className="mt-8 flex items-center justify-center w-full py-4 border-2 border-emerald-100 bg-emerald-50/50 text-emerald-600 rounded-2xl text-xs font-bold hover:bg-emerald-100 transition-all">
<FileSpreadsheet className="w-4 h-4 mr-2" />
표준 등록 템플릿 (Excel) 다운로드
</button>
</div>
{/* Upload Card - Textarea Input */}
<div className="bg-white p-8 rounded-[32px] border border-slate-200 shadow-sm flex flex-col justify-between">
<div>
<div className="w-12 h-12 rounded-2xl bg-blue-50 flex items-center justify-center mb-6">
<Upload className="w-6 h-6 text-blue-500" />
</div>
<h3 className="text-lg font-bold text-slate-900 mb-2">2. 데이터 입력</h3>
<p className="text-xs text-slate-500 leading-relaxed mb-4">
각 줄마다 장비를 입력하세요: type,manufacturer,model,serial,assetCode,location,purchaseDate
</p>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder={`LAPTOP,Samsung,NT500,SN-001,AST-2025-001,Seoul,2025-01-15
MONITOR,LG,27UP850,SN-002,AST-2025-002,Busan,2025-02-20`}
className="w-full h-40 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono focus:ring-2 focus:ring-primary/20 outline-none resize-none"
/>
</div>
<button
onClick={parseInput}
disabled={!inputText.trim()}
className="mt-4 flex items-center justify-center w-full py-4 bg-primary text-white rounded-2xl text-xs font-bold hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
데이터 파싱 및 검증
</button>
</div>
</div>
{/* Step 2: Validation Results (Visible only after upload) */}
{isUploaded && (
<div className="bg-white rounded-[32px] border border-slate-200 shadow-lg overflow-hidden animate-in slide-in-from-top-4 duration-500">
<div className="px-8 py-6 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
<div className="flex items-center space-x-4">
<h3 className="text-sm font-bold text-slate-900 flex items-center">
<CheckCircle2 className="w-4 h-4 mr-2 text-emerald-500" />
데이터 유효성 검증 결과
</h3>
<div className="flex items-center space-x-2">
<span className="px-2 py-0.5 bg-red-50 text-red-500 text-[10px] font-bold rounded border border-red-100">
Error {validationResults.filter(r => r.status === 'Error').length}
</span>
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-500 text-[10px] font-bold rounded border border-emerald-100">
Success {validationResults.filter(r => r.status === 'Success').length}
</span>
{successCount > 0 && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-500 text-[10px] font-bold rounded border border-blue-100">
Registered {successCount}
</span>
)}
</div>
</div>
<button onClick={handleReset} className="flex items-center text-[11px] font-bold text-slate-400 hover:text-slate-600 transition-colors">
<RefreshCcw className="w-3.5 h-3.5 mr-1.5" />
다시 업로드
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 border-b border-slate-100 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
<th className="px-8 py-4">행 (Row)</th>
<th className="px-8 py-4">검증 상태</th>
<th className="px-8 py-4">대상 필드</th>
<th className="px-8 py-4">입력 값</th>
<th className="px-8 py-4">메시지</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{validationResults.map((res, idx) => (
<tr key={idx} className="hover:bg-slate-50/50 transition-colors">
<td className="px-8 py-4 text-xs font-mono font-bold text-slate-500">{res.row}</td>
<td className="px-8 py-4">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold border ${
res.status === 'Error' ? 'bg-red-50 text-red-500 border-red-100' :
res.status === 'Warning' ? 'bg-orange-50 text-orange-500 border-orange-100' :
'bg-emerald-50 text-emerald-500 border-emerald-100'
}`}>
{res.status === 'Error' && <AlertTriangle className="w-3 h-3 mr-1.5" />}
{res.status}
</span>
</td>
<td className="px-8 py-4 text-xs font-bold text-slate-700">{res.field}</td>
<td className="px-8 py-4 text-xs font-mono text-slate-500">{res.value}</td>
<td className="px-8 py-4 text-xs text-slate-600">{res.message}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-8 py-6 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between">
<p className="text-xs text-slate-500">
<span className="font-bold text-red-500">주의:</span> 오류가 있는 행을 모두 수정해야 일괄 등록이 가능합니다.
</p>
<button
onClick={handleBatchRegister}
disabled={isProcessing || validationResults.some(r => r.status === 'Error')}
className="bg-primary text-white px-8 py-3 rounded-2xl text-xs font-bold hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
{isProcessing ? (
<>
<RefreshCcw className="w-4 h-4 mr-2 animate-spin" />
등록 중... ({successCount}건 완료)
</>
) : (
`일괄 등록 완료 (${parsedItems.filter(i => !i.error).length}건)`
)}
</button>
</div>
</div>
)}
{/* Batch Tips */}
<div className="p-6 bg-slate-900 rounded-[32px] text-white flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-primary" />
</div>
<div>
<h4 className="text-sm font-bold">대량 등록 팁</h4>
<p className="text-[11px] text-slate-400 mt-0.5">시리얼 번호가 중복될 경우 시스템이 자동으로 기존 자산 업데이트 또는 오류로 분류합니다.</p>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,440 @@
'use client';
import React, { useState } from 'react';
import {
Camera,
Scan,
CheckCircle2,
PlusCircle,
Trash2,
History,
Monitor,
Cpu,
Database,
Image as ImageIcon,
AlertCircle,
QrCode
} from 'lucide-react';
import equipmentService from '@/services/equipmentService';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import type { Equipment, EquipmentType } from '@/types';
import AssetLabel from './AssetLabel';
import BarcodeScanner from './BarcodeScanner';
interface SingleRegistrationFormProps {
onRegisterSuccess: () => void;
}
/**
* 단건/연속 등록 폼 컴포넌트
* 전문가 자문을 반영한 지능형 필드와 스캔 기능을 포함합니다.
*/
export default function SingleRegistrationForm({ onRegisterSuccess }: SingleRegistrationFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [tempList, setTempList] = useState<any[]>([]);
const [uploadedImages, setUploadedImages] = useState<{ url: string; name: string }[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const [labelAsset, setLabelAsset] = useState<{ assetCode: string; modelName: string; serialNumber: string; manufacturer: string; purchaseDate: string } | null>(null);
const [showScanner, setShowScanner] = useState(false);
const { codes: equipmentTypeCodes, loading: typeCodesLoading } = useCommonCodes('EQUIPMENT_TYPE');
const [formData, setFormData] = useState<Partial<Equipment>>({
equipmentType: 'LAPTOP',
manufacturer: '',
modelName: '',
serialNumber: '',
assetCode: '',
specifications: {} as Record<string, string>,
status: 'AVAILABLE',
purchaseDate: '',
warrantyExpDate: '',
});
const typeMapping: Record<string, EquipmentType> = {
'LAPTOP': 'LAPTOP',
'DESKTOP': 'ACCESSORY',
'SERVER': 'ACCESSORY',
'MONITOR': 'MONITOR',
};
const getSmartSpec = (type: string) => {
switch (type) {
case 'LAPTOP': return JSON.stringify({ os: 'Win 11 Pro', ram: '16GB', storage: '512GB' });
case 'DESKTOP': return JSON.stringify({ os: 'Win 11 Pro', ram: '32GB', storage: '1TB' });
case 'SERVER': return JSON.stringify({ os: 'Linux Ubuntu 22.04', ram: '64GB', storage: '2TB' });
case 'MONITOR': return JSON.stringify({ size: '27inch', resolution: '4K UHD' });
default: return '{}';
}
};
const handleInputChange = (field: keyof Equipment, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
setErrorMessage(null);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files?.length) return;
setUploadingImage(true);
for (const file of Array.from(files)) {
try {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/v1/files/upload', { method: 'POST', body: form });
const data = await res.json();
if (data.success) {
setUploadedImages(prev => [...prev, { url: data.data, name: file.name }]);
}
} catch {
// skip failed uploads
}
}
setUploadingImage(false);
e.target.value = '';
};
const removeImage = (idx: number) => {
setUploadedImages(prev => prev.filter((_, i) => i !== idx));
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setErrorMessage(null);
try {
const payload: Record<string, unknown> = {
...formData,
equipmentType: typeMapping[formData.equipmentType as string] || 'LAPTOP',
specifications: (() => {
const spec = formData.specifications;
if (typeof spec === 'string') {
try { return JSON.parse(spec); } catch { return {} as Record<string, string>; }
}
return spec || {};
})(),
};
if (uploadedImages.length > 0) {
payload.images = JSON.stringify(uploadedImages);
}
await equipmentService.create(payload);
const p = payload as Record<string, string>;
setLabelAsset({
assetCode: p.assetCode || '',
modelName: p.modelName || '',
serialNumber: p.serialNumber || '',
manufacturer: p.manufacturer || '',
purchaseDate: p.purchaseDate || '',
});
setTempList(prev => [{
id: formData.assetCode || `TMP-${Math.floor(Math.random()*1000)}`,
type: formData.equipmentType,
model: formData.modelName
}, ...prev]);
onRegisterSuccess();
setFormData({
equipmentType: 'LAPTOP',
manufacturer: '',
modelName: '',
serialNumber: '',
assetCode: '',
specifications: {} as Record<string, string>,
status: 'AVAILABLE',
});
setUploadedImages([]);
} catch (error: any) {
setErrorMessage(error.response?.data?.message || '등록에 실패했습니다. 다시 시도해 주세요.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left: Input Form (8 cols) */}
<div className="lg:col-span-8 space-y-8">
<form onSubmit={handleRegister} className="bg-white p-8 rounded-[32px] border border-slate-200 shadow-sm space-y-8">
{/* Section 1: Scan & Identity */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest flex items-center">
<Scan className="w-4 h-4 mr-2" />
Scan & Identification
</h3>
<div className="flex items-center space-x-6">
{/* Camera Scanner Button */}
<button type="button" onClick={() => setShowScanner(true)} className="relative w-full sm:w-48 h-24 sm:h-32 rounded-2xl sm:rounded-[24px] border-2 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all border-slate-200 bg-slate-50 hover:border-primary hover:bg-primary/5">
<Camera className="w-6 h-6 sm:w-8 sm:h-8 text-slate-300 mb-1 sm:mb-2" />
<p className="text-[11px] sm:text-[10px] font-bold text-slate-400">바코드/QR 스캔</p>
<p className="text-[9px] sm:text-[8px] text-slate-300 mt-0.5 sm:mt-1">카메라로 인식</p>
</button>
<div className="flex-1 space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-500 ml-1">자산 유형</label>
<div className="flex space-x-2">
{typeCodesLoading ? (
<span className="text-xs text-slate-400">Loading...</span>
) : (
equipmentTypeCodes.map((type) => (
<button
key={type.codeValue}
type="button"
onClick={() => handleInputChange('equipmentType', type.codeValue)}
className={`flex-1 py-2.5 rounded-xl text-xs font-bold border transition-all ${
formData.equipmentType === type.codeValue ? 'bg-slate-900 text-white border-slate-900 shadow-md' : 'bg-white text-slate-400 border-slate-200 hover:border-slate-300'
}`}
>
{type.codeName}
</button>
))
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-500 ml-1">시리얼 번호 (S/N)</label>
<input
type="text"
placeholder="스캔 시 자동 입력"
value={formData.serialNumber}
onChange={(e) => handleInputChange('serialNumber', e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-500 ml-1 flex items-center">
하드웨어 사양
<span className="ml-2 text-[9px] text-primary bg-primary/10 px-1 rounded">Smart Default</span>
</label>
<input
type="text"
value={typeof formData.specifications === 'string' ? formData.specifications : JSON.stringify(formData.specifications)}
onChange={(e) => handleInputChange('specifications', e.target.value)}
className="w-full px-4 py-3 bg-indigo-50/30 border border-indigo-100 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
</div>
</div>
</div>
</div>
{/* Section 2: Details */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-500 ml-1">제조사</label>
<input
type="text"
placeholder="예: Samsung, LG, Apple"
value={formData.manufacturer}
onChange={(e) => handleInputChange('manufacturer', e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-500 ml-1">모델명</label>
<input
type="text"
placeholder="예: MacBook Pro 14"
value={formData.modelName}
onChange={(e) => handleInputChange('modelName', e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-500 ml-1">자산 코드</label>
<input
type="text"
placeholder="예: AST-2025-001"
value={formData.assetCode}
onChange={(e) => handleInputChange('assetCode', e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-500 ml-1">구매일</label>
<input
type="date"
value={formData.purchaseDate}
onChange={(e) => handleInputChange('purchaseDate', e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-slate-500 ml-1">무상보증기간</label>
<input
type="date"
value={formData.warrantyExpDate}
onChange={(e) => handleInputChange('warrantyExpDate', e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
</div>
{/* Section 3: Equipment Photos (다중 업로드) */}
<div className="space-y-3">
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest flex items-center">
<ImageIcon className="w-4 h-4 mr-2" />
Equipment Photos
<span className="ml-2 text-[9px] text-primary bg-primary/10 px-1.5 py-0.5 rounded">Multiple</span>
</h3>
<div className="flex flex-wrap gap-3">
{uploadedImages.map((img, i) => (
<div key={i} className="relative group w-24 h-24 rounded-2xl overflow-hidden border border-slate-200">
<img src={img.url} alt={img.name} className="w-full h-full object-cover" />
<button type="button" onClick={() => removeImage(i)} className="absolute top-1 right-1 w-5 h-5 bg-black/50 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all">
<Trash2 className="w-3 h-3 text-white" />
</button>
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/50 to-transparent p-1">
<p className="text-[8px] text-white truncate">{img.name}</p>
</div>
</div>
))}
<label className="w-24 h-24 border-2 border-dashed border-slate-200 rounded-2xl flex flex-col items-center justify-center hover:bg-slate-50 hover:border-slate-300 transition-all cursor-pointer">
{uploadingImage ? (
<div className="w-5 h-5 border-2 border-slate-300 border-t-primary rounded-full animate-spin" />
) : (
<>
<PlusCircle className="w-6 h-6 text-slate-300 mb-1" />
<p className="text-[8px] font-bold text-slate-400 text-center px-1">Add Photo</p>
</>
)}
<input type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} disabled={uploadingImage} />
</label>
</div>
</div>
{/* Error Message */}
{errorMessage && (
<div className="p-4 bg-red-50 border border-red-100 rounded-xl flex items-center">
<AlertCircle className="w-4 h-4 text-red-500 mr-2" />
<p className="text-xs text-red-600 font-medium">{errorMessage}</p>
</div>
)}
{/* Submit Button (Continuous Mode UX) */}
<div className="pt-6 border-t border-slate-100 flex items-center justify-between">
<p className="text-xs text-slate-400 flex items-center">
<AlertCircle className="w-3.5 h-3.5 mr-1.5" />
저장 시 자동으로 다음 장비 등록 상태로 전환됩니다.
</p>
<div className="flex space-x-3">
<button
type="button"
onClick={() => setFormData({
equipmentType: 'LAPTOP',
manufacturer: '',
modelName: '',
serialNumber: '',
assetCode: '',
specifications: {} as Record<string, string>,
status: 'AVAILABLE',
purchaseDate: '',
warrantyExpDate: '',
})}
className="px-6 py-3 rounded-2xl text-sm font-bold text-slate-500 hover:bg-slate-100 transition-all"
>
초기화
</button>
<button
type="submit"
disabled={isSubmitting}
className="bg-slate-900 text-white px-10 py-3 rounded-2xl text-sm font-bold shadow-xl shadow-slate-200 hover:bg-slate-800 transition-all flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
{isSubmitting ? '등록 중...' : '저장 후 계속 (Next)'}
</button>
</div>
</div>
</form>
</div>
{/* Right: Temp Registered List (4 cols) */}
<div className="lg:col-span-4 space-y-6">
<div className="bg-white p-6 rounded-[32px] border border-slate-200 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-sm font-bold text-slate-900 flex items-center">
<History className="w-4 h-4 mr-2 text-primary" />
임시 등록 리스트
</h3>
<span className="text-[11px] font-bold bg-slate-100 px-2 py-0.5 rounded-full text-slate-500">
{tempList.length}건 대기
</span>
</div>
<div className="space-y-3">
{tempList.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-slate-300">
<Database className="w-10 h-10 mb-2 opacity-20" />
<p className="text-xs font-bold">아직 등록된 자산이 없습니다.</p>
</div>
) : (
tempList.map((item, idx) => (
<div key={idx} className="group p-4 bg-slate-50 rounded-2xl border border-slate-100 flex items-center justify-between hover:bg-white hover:border-primary/20 hover:shadow-sm transition-all">
<div className="flex items-center">
<div className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center mr-3">
<Cpu className="w-4 h-4 text-slate-400" />
</div>
<div>
<p className="text-[10px] font-bold text-slate-400">{item.id}</p>
<p className="text-xs font-bold text-slate-900">{item.model}</p>
</div>
</div>
<button className="p-2 opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition-all">
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
{tempList.length > 0 && (
<button className="w-full mt-6 bg-primary text-white py-3 rounded-2xl text-xs font-bold shadow-lg shadow-primary/20 hover:bg-primary-hover transition-all">
최종 일괄 전송 ({tempList.length}건)
</button>
)}
</div>
{/* Pro Tip Card */}
<div className="bg-gradient-to-br from-slate-800 to-slate-900 p-6 rounded-[32px] text-white">
<h4 className="text-xs font-bold text-primary mb-2 uppercase tracking-widest">Pro Tip</h4>
<p className="text-xs text-slate-300 leading-relaxed">
바코드 스캔 시 모델 정보가 DB에 있다면 모든 사양 정보가 자동으로 채워집니다. 시리얼 번호 중복 여부도 실시간으로 체크됩니다.
</p>
</div>
</div>
{showScanner && (
<BarcodeScanner
onScan={(value) => {
if (value.length >= 8 && /^[A-Za-z0-9-]+$/.test(value)) {
if (value.startsWith('HQ-') || value.startsWith('AST-')) {
setFormData((prev) => ({ ...prev, assetCode: value }));
} else {
setFormData((prev) => ({ ...prev, serialNumber: value }));
}
}
setShowScanner(false);
}}
onClose={() => setShowScanner(false)}
/>
)}
{labelAsset && (
<AssetLabel
assetCode={labelAsset.assetCode}
modelName={labelAsset.modelName}
serialNumber={labelAsset.serialNumber}
manufacturer={labelAsset.manufacturer}
purchaseDate={labelAsset.purchaseDate}
onClose={() => setLabelAsset(null)}
/>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { ShieldAlert } from 'lucide-react';
import { useAuthStore } from '@/stores';
import { getCookie } from '@/lib/cookie';
const publicPaths = ['/login'];
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated, fetchMe, tryRefresh, accessToken } = useAuthStore();
const [loading, setLoading] = useState(true);
useEffect(() => {
if (publicPaths.includes(pathname)) {
setLoading(false);
return;
}
const init = async () => {
const token = accessToken || localStorage.getItem('accessToken') || getCookie('accessToken');
if (!token) {
const hasRefresh = localStorage.getItem('refreshToken');
if (hasRefresh) {
const renewed = await tryRefresh();
if (renewed) {
await fetchMe().catch(() => {
router.replace('/login');
return;
});
setLoading(false);
return;
}
}
router.replace('/login');
return;
}
if (!isAuthenticated) {
await fetchMe().catch(() => {
router.replace('/login');
return;
});
}
setLoading(false);
};
init();
}, [pathname]);
if (publicPaths.includes(pathname)) return <>{children}</>;
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-canvas">
<div className="text-center">
<ShieldAlert className="w-10 h-10 text-primary mx-auto mb-3 animate-pulse" />
<p className="text-text-muted text-sm">인증 확인 중...</p>
</div>
</div>
);
}
return <>{children}</>;
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import React, { useEffect, useState } from 'react';
import { Search, Calendar, MessageSquare, Bell, ChevronsRight, ChevronsLeft, Plus, Menu } from 'lucide-react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuthStore, useNotificationStore } from '@/stores';
import NotificationDropdown from './NotificationDropdown';
import UserDropdown from './UserDropdown';
const Header = ({ toggleSidebar, isCollapsed, onOpenMobile }: { toggleSidebar: () => void, isCollapsed: boolean, onOpenMobile?: () => void }) => {
const router = useRouter();
const unreadCount = useNotificationStore((s) => s.unreadCount);
const startPolling = useNotificationStore((s) => s.startPolling);
const [searchVal, setSearchVal] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
useEffect(() => {
startPolling();
}, []);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchVal.trim()) {
router.push(`/issues?search=${encodeURIComponent(searchVal.trim())}`);
}
};
return (
<header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6 shrink-0 z-30 shadow-sm">
<div className="flex items-center flex-1">
<button
onClick={toggleSidebar}
className="mr-4 lg:mr-6 text-text-muted hover:text-text-main transition-colors p-1 hover:bg-gray-100 rounded-lg lg:block hidden"
>
{isCollapsed ? <ChevronsRight className="w-5 h-5" /> : <ChevronsLeft className="w-5 h-5" />}
</button>
<button
onClick={() => onOpenMobile?.()}
className="mr-4 lg:mr-6 text-text-muted hover:text-text-main transition-colors p-1 hover:bg-gray-100 rounded-lg lg:hidden block"
>
<Menu className="w-5 h-5" />
</button>
<form onSubmit={handleSearch} className="relative w-full max-w-md">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="w-4 h-4 text-gray-400" />
</div>
<input
type="text"
value={searchVal}
onChange={(e) => setSearchVal(e.target.value)}
placeholder="Search issues, assets, employees..."
className="block w-full pl-10 pr-3 py-2 border-none rounded-xl bg-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all text-text-main"
/>
</form>
</div>
<div className="flex items-center space-x-4">
<button onClick={() => router.push('/calendar')} className="p-2 text-text-muted hover:bg-gray-100 rounded-full transition-colors" title="일정 보기">
<Calendar className="w-5 h-5" />
</button>
<button className="p-2 text-text-muted hover:bg-gray-100 rounded-full transition-colors relative" title="메시지 보기">
<MessageSquare className="w-5 h-5" />
</button>
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="p-2 text-text-muted hover:bg-gray-100 rounded-full transition-colors relative mr-2"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-status-critical-text text-white text-[10px] font-bold rounded-full flex items-center justify-center border-2 border-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<NotificationDropdown isOpen={isDropdownOpen} onClose={() => setIsDropdownOpen(false)} />
</div>
<Link href="/issues" className="bg-primary hover:bg-primary-hover text-white px-4 py-2 rounded-xl text-xs font-semibold shadow-soft transition-all flex items-center">
<Plus className="w-3.5 h-3.5 mr-1.5" />
New SR
</Link>
<div className="h-8 w-px bg-gray-200"></div>
<UserDropdown />
</div>
</header>
);
};
export default Header;
+44
View File
@@ -0,0 +1,44 @@
"use client";
import React, { useState } from 'react';
import Sidebar from './Sidebar';
import Header from './Header';
import AuthGuard from './AuthGuard';
import MobileBottomNav from './MobileBottomNav';
export default function MainShell({ children }: { children: React.ReactNode }) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const toggleSidebar = () => setIsCollapsed(!isCollapsed);
return (
<AuthGuard>
<div className="h-full flex overflow-hidden">
{/* Desktop Sidebar - hidden on mobile */}
<div className="hidden lg:block">
<Sidebar isCollapsed={isCollapsed} />
</div>
{/* Mobile sidebar overlay (drawer) */}
{mobileMenuOpen && (
<div className="fixed inset-0 z-50 lg:hidden" onClick={() => setMobileMenuOpen(false)}>
<div className="absolute inset-0 bg-black/40" />
<div className="absolute left-0 top-0 bottom-0 w-[250px] bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
<Sidebar isCollapsed={false} />
</div>
</div>
)}
<div className="flex-1 flex flex-col min-w-0 bg-canvas">
<Header toggleSidebar={toggleSidebar} isCollapsed={isCollapsed} onOpenMobile={() => setMobileMenuOpen(true)} />
<main className="flex-1 overflow-auto pb-16 lg:pb-0">
{children}
</main>
</div>
</div>
{/* Mobile bottom navigation */}
<MobileBottomNav />
</AuthGuard>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Ticket, Link2, Monitor, FileText } from 'lucide-react';
const navItems = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Issues', href: '/issues', icon: Ticket },
{ name: 'Dispatch', href: '/dispatch', icon: Link2 },
{ name: 'ITAM', href: '/itam', icon: Monitor },
{ name: 'Reports', href: '/work-reports', icon: FileText },
];
export default function MobileBottomNav() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 inset-x-0 z-50 bg-white border-t border-border flex items-center justify-around py-1 safe-area-bottom lg:hidden shadow-[0_-2px_10px_rgba(0,0,0,0.06)]">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link key={item.name} href={item.href} className={`flex flex-col items-center py-1 px-3 rounded-xl transition-colors ${isActive ? 'text-primary' : 'text-text-muted'}`}>
<Icon className="w-5 h-5" />
<span className="text-[10px] font-medium mt-0.5">{item.name}</span>
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,167 @@
"use client";
import React, { useEffect, useRef } from 'react';
import { Bell, Check, AlertCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
import { useNotificationStore } from '@/stores';
const formatTimeAgo = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return '방금 전';
if (diffMin < 60) return `${diffMin}분 전`;
if (diffHour < 24) return `${diffHour}시간 전`;
if (diffDay < 7) return `${diffDay}일 전`;
if (diffDay < 30) return `${Math.floor(diffDay / 7)}주 전`;
return date.toLocaleDateString('ko-KR');
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'SR_TICKET':
return <AlertCircle className="w-4 h-4 text-blue-500" />;
case 'SR_UPDATE':
return <Info className="w-4 h-4 text-indigo-500" />;
case 'SLA_WARNING':
return <AlertTriangle className="w-4 h-4 text-amber-500" />;
case 'REPORT_APPROVED':
case 'REPORT_REJECTED':
return <Check className="w-4 h-4 text-emerald-500" />;
case 'EQUIPMENT':
return <Bell className="w-4 h-4 text-purple-500" />;
case 'ERROR':
return <XCircle className="w-4 h-4 text-red-500" />;
default:
return <Bell className="w-4 h-4 text-gray-500" />;
}
};
interface NotificationDropdownProps {
isOpen: boolean;
onClose: () => void;
}
const NotificationDropdown = ({ isOpen, onClose }: NotificationDropdownProps) => {
const dropdownRef = useRef<HTMLDivElement>(null);
const notifications = useNotificationStore((s) => s.notifications);
const markAsRead = useNotificationStore((s) => s.markAsRead);
const fetchNotifications = useNotificationStore((s) => s.fetchNotifications);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
fetchNotifications();
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose, fetchNotifications]);
const handleNotificationClick = async (id: number) => {
await markAsRead(id);
};
const handleMarkAllRead = async () => {
const unreadIds = notifications.filter((n) => !n.isRead).map((n) => n.notificationId);
for (const id of unreadIds) {
await markAsRead(id);
}
};
if (!isOpen) return null;
const unreadNotifications = notifications.filter((n) => !n.isRead);
return (
<div
ref={dropdownRef}
className="absolute right-0 top-full mt-2 w-96 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-50"
style={{ maxHeight: '500px' }}
>
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-gray-50">
<h3 className="text-sm font-semibold text-text-main">알림</h3>
{unreadNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-xs text-primary hover:text-primary-hover font-medium transition-colors"
>
모두 읽음
</button>
)}
</div>
{/* Notification List */}
<div className="overflow-y-auto" style={{ maxHeight: '420px' }}>
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center">
<Bell className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-text-muted">알림이 없습니다</p>
</div>
) : (
<ul className="divide-y divide-gray-50">
{notifications.map((notification) => (
<li
key={notification.notificationId}
onClick={() => handleNotificationClick(notification.notificationId)}
className={`px-4 py-3 cursor-pointer transition-colors hover:bg-gray-50 ${
!notification.isRead ? 'bg-blue-50/50 hover:bg-blue-50' : ''
}`}
>
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">{getTypeIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<p
className={`text-sm font-medium truncate ${
!notification.isRead ? 'text-text-main' : 'text-text-muted'
}`}
>
{notification.title}
</p>
{!notification.isRead && (
<span className="w-2 h-2 bg-primary rounded-full shrink-0" />
)}
</div>
<p className="text-xs text-text-muted line-clamp-2 mb-1">
{notification.message}
</p>
<span className="text-[10px] text-text-muted">
{formatTimeAgo(notification.createdAt)}
</span>
</div>
</div>
</li>
))}
</ul>
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-gray-100 bg-gray-50 text-center">
<button
onClick={onClose}
className="text-xs text-text-muted hover:text-text-main transition-colors"
>
닫기
</button>
</div>
)}
</div>
);
};
export default NotificationDropdown;
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useState, useCallback, useRef } from 'react';
import { Moon, RefreshCw, Clock } from 'lucide-react';
const BACKOFF_MS = [5_000, 10_000, 20_000, 40_000, 60_000];
export default function ParkingGuard({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState<'loading' | 'parked' | 'online'>('loading');
const backoffIdx = useRef(0);
const timerRef = useRef<number | undefined>(undefined);
const checkHealth = useCallback(async () => {
try {
const res = await fetch('/api/v1/health');
if (!res.ok) { setStatus('parked'); return; }
const body = await res.json();
setStatus(body.data?.parked ? 'parked' : 'online');
} catch {
setStatus('parked');
}
}, []);
useEffect(() => {
checkHealth();
const onVisible = () => {
if (document.visibilityState === 'visible') {
clearTimeout(timerRef.current);
backoffIdx.current = 0;
checkHealth();
}
};
document.addEventListener('visibilitychange', onVisible);
return () => {
document.removeEventListener('visibilitychange', onVisible);
clearTimeout(timerRef.current);
};
}, [checkHealth]);
useEffect(() => {
if (status !== 'parked') return;
const delay = BACKOFF_MS[Math.min(backoffIdx.current, BACKOFF_MS.length - 1)];
if (backoffIdx.current < BACKOFF_MS.length - 1) backoffIdx.current++;
timerRef.current = window.setTimeout(checkHealth, delay);
return () => clearTimeout(timerRef.current);
}, [status, checkHealth]);
if (status === 'loading') return null;
if (status === 'parked') {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-amber-50 flex items-center justify-center">
<Moon className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-xl font-semibold text-text-main mb-2 font-outfit">
서버 점검 시간입니다
</h2>
<div className="bg-amber-50 rounded-xl px-4 py-3 mb-5 text-sm text-amber-800">
<div className="flex items-center justify-center gap-2">
<Clock className="w-4 h-4" />
<span>점검 시간: 토/일요일 · 평일 20:00~08:00</span>
</div>
</div>
<p className="text-sm text-text-muted mb-4">
백엔드 서버가 절전 모드입니다. 서버가 기동되면 자동으로 접속됩니다.
</p>
<button
onClick={() => { backoffIdx.current = 0; checkHealth(); }}
className="w-full flex items-center justify-center gap-2 bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold transition-all shadow-soft"
>
<RefreshCw className="w-4 h-4" />
서버 상태 확인
</button>
</div>
</div>
);
}
return <>{children}</>;
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Moon, Sun, Clock, Send, CheckCircle, Power } from 'lucide-react';
import { useParkingStore } from '@/stores';
const RESUME_SCHEDULE = '월요일 08:00';
const ParkingOverlay = () => {
const { parked, checking, wakeLoading, wakeSent, wakeMessage, requestWake, dismissWake } = useParkingStore();
const [reason, setReason] = useState('');
if (checking || !parked) return null;
const now = new Date();
const day = now.getDay();
const hour = now.getHours();
const isWeekend = day === 0 || day === 6;
const resumeTime = isWeekend
? '월요일 08:00'
: `오늘 ${hour < 8 ? '08:00' : '다음 영업일 08:00'}`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-amber-50 flex items-center justify-center">
<Moon className="w-8 h-8 text-amber-500" />
</div>
<h2 className="text-xl font-semibold text-text-main mb-2 font-outfit">
서버 점검 시간입니다
</h2>
<div className="bg-amber-50 rounded-xl px-4 py-3 mb-5 text-sm text-amber-800 space-y-1">
<div className="flex items-center justify-center gap-2">
<Clock className="w-4 h-4" />
<span>예상 재기동: <strong>{resumeTime}</strong></span>
</div>
<div className="text-amber-600 text-xs">
점검 시간: 토/일요일 (주말) · 평일 20:00~08:00
</div>
</div>
{!wakeSent ? (
<>
<p className="text-sm text-text-muted mb-4">
긴급하게 서버가 필요하면 아래 버튼으로 관리자에게 기동 요청을 보낼 수 있습니다.
</p>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="요청 사유 (예: 긴급 SR 처리)"
className="w-full px-4 py-2.5 border border-border rounded-xl text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent bg-white"
maxLength={100}
/>
<button
onClick={() => requestWake(reason || '긴급 서버 기동 요청')}
disabled={wakeLoading}
className="w-full flex items-center justify-center gap-2 bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-soft"
>
{wakeLoading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<Power className="w-4 h-4" />
서버 기동 요청
</>
)}
</button>
</>
) : (
<div className="bg-emerald-50 rounded-xl px-4 py-5">
<CheckCircle className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
<p className="text-sm text-emerald-800 font-medium mb-1">요청이 전송되었습니다</p>
<p className="text-xs text-emerald-600">{wakeMessage}</p>
<button
onClick={dismissWake}
className="mt-3 text-xs text-text-muted hover:text-text-main underline"
>
다시 요청하기
</button>
</div>
)}
<div className="mt-5 pt-4 border-t border-border">
<a
href="mailto:admin@example.com"
className="text-xs text-text-muted hover:text-primary transition-colors"
>
관리자 직접 연락: admin@example.com
</a>
</div>
</div>
</div>
);
};
export default ParkingOverlay;
+315
View File
@@ -0,0 +1,315 @@
"use client";
import React, { useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard,
Ticket,
Link2,
Monitor,
FileText,
Plus,
Lightbulb,
ChevronDown,
PlusCircle,
ArrowUpRight,
ArrowDownLeft,
History,
BookOpen,
Users,
ClipboardCheck,
CalendarDays,
Settings,
CheckSquare,
Briefcase,
} from 'lucide-react';
import { useAuthStore } from '@/stores';
/**
* SR 시스템의 메인 사이드바 컴포넌트
* ITAM 하위 메뉴를 포함하도록 고도화되었습니다.
*/
const Sidebar = ({ isCollapsed }: { isCollapsed?: boolean }) => {
const pathname = usePathname();
const router = useRouter();
const [isItamOpen, setIsItamOpen] = useState(pathname.startsWith('/itam'));
const user = useAuthStore((s) => s.user);
const role = user?.roleCode;
const isMgmt = role === 'MANAGER' || role === 'ADMIN';
const menuItems = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'SR Issues', href: '/issues', icon: Ticket },
{ name: 'Dispatch', href: '/dispatch', icon: Link2 },
];
const itamSubItems = [
{ name: 'Asset Hub', href: '/itam', icon: Monitor },
{ name: 'Register', href: '/itam/register', icon: PlusCircle },
{ name: 'Check-out', href: '/itam/checkout', icon: ArrowUpRight },
{ name: 'Check-in', href: '/itam/checkin', icon: ArrowDownLeft },
{ name: 'History', href: '/itam', icon: History },
];
const projects = [
{ name: 'Samsung Pay SI', dot: 'bg-dot-green' },
{ name: 'LG CNS SM', dot: 'bg-dot-orange' },
{ name: 'Kakao Maintenance', dot: 'bg-dot-blue' },
];
return (
<aside
className={`bg-surface border-r border-border flex flex-col justify-between flex-shrink-0 transition-all duration-300 relative z-20 overflow-hidden ${
isCollapsed ? 'w-[64px]' : 'w-[250px]'
}`}
>
<div className="flex-1 overflow-y-auto no-scrollbar">
{/* 로고 영역 */}
<div className={`h-16 flex items-center border-b border-border transition-all sticky top-0 bg-surface z-10 ${isCollapsed ? 'justify-center' : 'px-6'}`}>
<div className="w-6 h-6 rounded-full bg-gradient-to-tr from-primary to-purple-400 flex-shrink-0 shadow-lg shadow-primary/20"></div>
{!isCollapsed && (
<h1 className="font-outfit font-semibold text-lg text-text-main tracking-wide ml-3 whitespace-nowrap">SR System.</h1>
)}
</div>
{/* 메인 메뉴 */}
<div className={`py-6 ${isCollapsed ? 'px-1' : 'px-4'}`}>
{!isCollapsed && (
<p className="text-[10px] font-bold text-text-muted tracking-[0.12em] mb-4 px-2 uppercase whitespace-nowrap">Main Menu</p>
)}
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
title={isCollapsed ? item.name : ''}
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
isActive
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<Icon className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">{item.name}</span>}
</Link>
);
})}
{/* Projects link (management only) */}
{isMgmt && (
<Link
href="/projects"
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname === '/projects'
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<Briefcase className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Projects</span>}
</Link>
)}
{/* ITAM Group Menu */}
<div className="space-y-1">
<button
onClick={() => !isCollapsed && setIsItamOpen(!isItamOpen)}
className={`w-full flex items-center justify-between rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname.startsWith('/itam') && !isCollapsed
? 'text-primary'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<div className="flex items-center">
<Monitor className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">ITAM (Assets)</span>}
</div>
{!isCollapsed && (
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-300 ${isItamOpen ? 'rotate-180' : ''}`} />
)}
</button>
{/* ITAM Sub Items */}
{isItamOpen && !isCollapsed && (
<div className="pl-6 space-y-1 mt-1 animate-in slide-in-from-top-2 duration-300">
{itamSubItems.map((sub) => {
const SubIcon = sub.icon;
const isActive = pathname === sub.href;
return (
<Link
key={sub.name}
href={sub.href}
className={`flex items-center px-3 py-2 rounded-lg text-[11px] font-bold transition-all ${
isActive
? 'text-primary bg-indigo-50/50'
: 'text-text-muted hover:text-text-main hover:bg-gray-50'
}`}
>
<SubIcon className="w-3.5 h-3.5 mr-2.5" />
<span className="whitespace-nowrap">{sub.name}</span>
</Link>
);
})}
</div>
)}
</div>
<Link
href="/work-reports"
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname === '/work-reports'
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<FileText className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Work Reports</span>}
</Link>
<Link
href="/daily-reports"
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname === '/daily-reports'
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<ClipboardCheck className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Daily Report</span>}
</Link>
{isMgmt && (
<Link
href="/work-reports/review"
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname === '/work-reports/review'
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<CheckSquare className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Report Review</span>}
</Link>
)}
{role === 'ADMIN' && (
<Link
href="/settings/codes"
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname === '/settings/codes'
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<Settings className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Settings</span>}
</Link>
)}
<Link
href="/calendar"
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname === '/calendar'
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<CalendarDays className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Calendar</span>}
</Link>
{isMgmt && (
<Link
href="/employees"
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname === '/employees'
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<Users className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Employees</span>}
</Link>
)}
</nav>
</div>
{/* 내 프로젝트 */}
<div className={`border-t border-border mt-4 pt-6 ${isCollapsed ? 'px-1 py-2' : 'px-4 py-2'}`}>
<div className={`flex items-center justify-between mb-4 ${isCollapsed ? 'justify-center' : 'px-2'}`}>
{!isCollapsed ? (
<>
<p className="text-[10px] font-bold text-text-muted tracking-[0.12em] uppercase whitespace-nowrap">My Projects</p>
<button onClick={() => router.push('/dispatch')} className="text-text-muted hover:text-text-main">
<Plus className="w-3.5 h-3.5" />
</button>
</>
) : (
<p className="text-[10px] font-bold text-text-muted uppercase">MY</p>
)}
</div>
<nav className="space-y-1">
{projects.map((project) => (
<Link
key={project.name}
href="/dispatch"
title={isCollapsed ? project.name : ''}
className={`flex items-center hover:bg-gray-50 rounded-xl text-xs font-bold text-text-muted transition-colors ${
isCollapsed ? 'justify-center py-2' : 'px-3 py-2'
}`}
>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${project.dot} ${isCollapsed ? '' : 'mr-3'}`}></span>
{!isCollapsed && <span className="whitespace-nowrap">{project.name}</span>}
</Link>
))}
</nav>
</div>
</div>
{/* 하단 정보 카드 */}
<div className={`${isCollapsed ? 'p-2' : 'p-6'}`}>
{isCollapsed ? (
<button className="w-10 h-10 mx-auto bg-primary rounded-xl flex items-center justify-center text-white shadow-soft hover:bg-primary-hover transition-colors">
<Lightbulb className="w-5 h-5" />
</button>
) : (
<div className="bg-indigo-50/50 rounded-2xl p-4 border border-indigo-100/50 text-center relative overflow-hidden group">
<div className="absolute -top-4 -right-4 w-12 h-12 bg-primary/10 rounded-full opacity-50 blur-xl group-hover:scale-150 transition-transform duration-700"></div>
<div className="w-8 h-8 mx-auto bg-white rounded-full flex items-center justify-center shadow-sm mb-3">
<span className="text-lg"><Lightbulb className="w-4.5 h-4.5 text-primary" /></span>
</div>
<h4 className="font-bold text-xs text-slate-800 mb-1.5 whitespace-nowrap">Need Assistance?</h4>
<p className="text-[10px] text-slate-500 leading-relaxed mb-4">현장 장비 부족이나 기술적 이슈는 즉시 보고하세요.</p>
<button onClick={() => router.push('/issues')} className="w-full py-2 bg-white rounded-xl text-[10px] font-bold text-primary shadow-sm hover:shadow-md transition-all border border-indigo-100">
HQ에 긴급 메시지
</button>
</div>
)}
</div>
</aside>
);
};
export default Sidebar;
+75
View File
@@ -0,0 +1,75 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { UserCircle, Settings, LogOut, ChevronDown } from 'lucide-react';
import { useAuthStore } from '@/stores';
export default function UserDropdown() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const roleLabel: Record<string, string> = {
ADMIN: '최고관리자',
MANAGER: '본사관리자',
EMPLOYEE: '파견직원',
};
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-3 pl-2 pr-1.5 py-1 rounded-xl hover:bg-gray-50 transition-all"
>
<div className="text-right hidden md:block">
<p className="text-sm font-medium text-text-main leading-tight">{user?.name || 'User'}</p>
<p className="text-xs text-text-muted">{roleLabel[user?.roleCode || ''] || user?.roleCode || 'HQ Center'}</p>
</div>
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-primary to-purple-400 flex items-center justify-center text-white text-sm font-semibold shadow-sm flex-shrink-0">
{user?.name?.[0] || 'U'}
</div>
<ChevronDown className={`w-4 h-4 text-text-muted transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute right-0 top-12 w-56 bg-white rounded-xl shadow-lg border border-border overflow-hidden z-50">
<div className="px-4 py-3 border-b border-border">
<p className="text-sm font-semibold text-text-main">{user?.name || 'User'}</p>
<p className="text-xs text-text-muted mt-0.5">{user?.email || ''}</p>
</div>
<div className="py-1">
<Link href="/profile" onClick={() => setOpen(false)} className="flex items-center gap-3 px-4 py-2.5 text-sm text-text-main hover:bg-gray-50 transition-colors">
<UserCircle className="w-4 h-4 text-text-muted" />
프로필 정보
</Link>
{user?.roleCode === 'ADMIN' && (
<Link href="/settings/codes" onClick={() => setOpen(false)} className="flex items-center gap-3 px-4 py-2.5 text-sm text-text-main hover:bg-gray-50 transition-colors">
<Settings className="w-4 h-4 text-text-muted" />
설정 (공통코드)
</Link>
)}
</div>
<div className="border-t border-border py-1">
<button onClick={() => { logout(); setOpen(false); window.location.href = '/login'; }} className="flex items-center gap-3 px-4 py-2.5 text-sm text-status-critical-text hover:bg-red-50 transition-colors w-full text-left">
<LogOut className="w-4 h-4" />
로그아웃
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,335 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { X, Search } from 'lucide-react';
import projectService from '@/services/projectService';
import employeeService from '@/services/employeeService';
import type { Project, EmployeeSearchResult } from '@/types';
interface Props {
isOpen: boolean;
onClose: () => void;
onSaved: () => void;
editData?: Project | null;
}
export default function ProjectFormModal({ isOpen, onClose, onSaved, editData }: Props) {
const isEdit = !!editData;
const [projectName, setProjectName] = useState('');
const [projectType, setProjectType] = useState<string>('SI');
const [contractCompany, setContractCompany] = useState('');
const [endUserCompany, setEndUserCompany] = useState('');
const [contractType, setContractType] = useState<string>('TURN_KEY');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [workLocationType, setWorkLocationType] = useState<string>('ON_SITE');
const [workAddress, setWorkAddress] = useState('');
const [techEnvironment, setTechEnvironment] = useState('');
const [mainTaskDesc, setMainTaskDesc] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Internal manager search
const [mgrSearch, setMgrSearch] = useState('');
const [mgrResults, setMgrResults] = useState<EmployeeSearchResult[]>([]);
const [selectedMgr, setSelectedMgr] = useState<EmployeeSearchResult | null>(null);
const [showMgrDropdown, setShowMgrDropdown] = useState(false);
const debounceRef = useRef<number | undefined>(undefined);
// Pre-populate on edit
useEffect(() => {
if (!editData) {
resetForm();
return;
}
setProjectName(editData.projectName || '');
setProjectType(editData.projectType || 'SI');
setContractCompany(editData.contractCompany || '');
setEndUserCompany(editData.endUserCompany || '');
setContractType(editData.contractType || 'TURN_KEY');
setStartDate(editData.startDate || '');
setEndDate(editData.endDate || '');
setWorkLocationType(editData.workLocationType || 'ON_SITE');
setWorkAddress(editData.workAddress || '');
setTechEnvironment(editData.techEnvironment || '');
setMainTaskDesc(editData.mainTaskDesc || '');
if (editData.internalManager) {
setSelectedMgr(editData.internalManager as EmployeeSearchResult);
setMgrSearch(editData.internalManager.name);
}
}, [editData, isOpen]);
const resetForm = () => {
setProjectName('');
setProjectType('SI');
setContractCompany('');
setEndUserCompany('');
setContractType('TURN_KEY');
setStartDate('');
setEndDate('');
setWorkLocationType('ON_SITE');
setWorkAddress('');
setTechEnvironment('');
setMainTaskDesc('');
setMgrSearch('');
setSelectedMgr(null);
setError(null);
};
const handleMgrSearch = (val: string) => {
setMgrSearch(val);
setShowMgrDropdown(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(() => {
if (val.trim().length >= 1) {
employeeService.search(val.trim()).then((res) => {
setMgrResults(res.data || []);
}).catch(() => {});
} else {
setMgrResults([]);
}
}, 300);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!projectName.trim() || !contractCompany.trim() || !startDate) {
setError('Project name, contract company, and start date are required.');
return;
}
setLoading(true);
setError(null);
try {
const payload: Record<string, unknown> = {
projectName: projectName.trim(),
projectType,
contractCompany: contractCompany.trim(),
endUserCompany: endUserCompany.trim() || undefined,
contractType,
startDate,
endDate: endDate || undefined,
workLocationType,
workAddress: workAddress.trim() || undefined,
techEnvironment: techEnvironment.trim() || undefined,
mainTaskDesc: mainTaskDesc.trim() || undefined,
internalManagerId: selectedMgr?.empId || null,
};
if (isEdit) {
await projectService.update(editData!.projectId, payload);
} else {
await projectService.create(payload);
}
onSaved();
onClose();
resetForm();
} catch {
setError('Failed to save project. Please try again.');
}
setLoading(false);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-6 border-b border-border">
<h3 className="font-outfit font-semibold text-lg text-text-main">
{isEdit ? 'Edit Project' : 'New Project'}
</h3>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg transition-colors">
<X className="w-5 h-5 text-text-muted" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">
Project Name <span className="text-status-critical-text">*</span>
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="e.g. Samsung Pay SI"
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Project Type</label>
<select
value={projectType}
onChange={(e) => setProjectType(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="SI">SI</option>
<option value="SM">SM</option>
<option value="MIGRATION">MIGRATION</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Contract Type</label>
<select
value={contractType}
onChange={(e) => setContractType(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="TURN_KEY">TURN_KEY</option>
<option value="MM">MM</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">
Contract Company <span className="text-status-critical-text">*</span>
</label>
<input
type="text"
value={contractCompany}
onChange={(e) => setContractCompany(e.target.value)}
placeholder="e.g. Samsung Electronics"
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">End User Company</label>
<input
type="text"
value={endUserCompany}
onChange={(e) => setEndUserCompany(e.target.value)}
placeholder="e.g. Samsung SDS"
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">
Start Date <span className="text-status-critical-text">*</span>
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Work Location Type</label>
<select
value={workLocationType}
onChange={(e) => setWorkLocationType(e.target.value)}
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="ON_SITE">ON_SITE</option>
<option value="REMOTE">REMOTE</option>
<option value="HYBRID">HYBRID</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Work Address</label>
<input
type="text"
value={workAddress}
onChange={(e) => setWorkAddress(e.target.value)}
placeholder="e.g. Seoul, Gangnam-gu"
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Tech Environment</label>
<input
type="text"
value={techEnvironment}
onChange={(e) => setTechEnvironment(e.target.value)}
placeholder="e.g. Java, Spring Boot, React, PostgreSQL"
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5">Main Task Description</label>
<textarea
value={mainTaskDesc}
onChange={(e) => setMainTaskDesc(e.target.value)}
placeholder="Describe the main tasks and goals of the project..."
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
rows={3}
/>
</div>
{/* Internal Manager Search */}
<div className="relative">
<label className="block text-xs font-medium text-text-muted mb-1.5">Internal Manager</label>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={mgrSearch}
onChange={(e) => handleMgrSearch(e.target.value)}
onFocus={() => setShowMgrDropdown(true)}
placeholder="Search manager by name..."
className="w-full pl-9 pr-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{showMgrDropdown && mgrResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border border-border rounded-xl shadow-lg max-h-48 overflow-y-auto">
{mgrResults.map((emp) => (
<button
key={emp.empId}
type="button"
onClick={() => { setSelectedMgr(emp); setMgrSearch(emp.name); setShowMgrDropdown(false); }}
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 transition-colors"
>
{emp.name} <span className="text-text-muted text-xs">({emp.email})</span>
</button>
))}
</div>
)}
{selectedMgr && !showMgrDropdown && (
<p className="text-xs text-primary mt-1">Selected: {selectedMgr.name}</p>
)}
</div>
{error && (
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-xl px-4 py-3 text-sm">
{error}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100 transition-colors">
Cancel
</button>
<button
type="submit"
disabled={loading || !projectName.trim() || !contractCompany.trim() || !startDate}
className="px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Project'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -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>
);
}
+31
View File
@@ -0,0 +1,31 @@
'use client';
import React from 'react';
interface BadgeProps {
variant?: 'open' | 'in-progress' | 'resolved' | 'closed' | 'critical';
children: React.ReactNode;
className?: string;
}
const variantStyles = {
open: 'bg-[#EFF6FF] text-[#3B82F6]',
'in-progress': 'bg-[#FFFBEB] text-[#F59E0B]',
resolved: 'bg-[#ECFDF5] text-[#10B981]',
closed: 'bg-[#F9FAFB] text-[#6B7280]',
critical: 'bg-[#FEF2F2] text-[#EF4444] font-semibold',
};
export default function Badge({
variant = 'closed',
children,
className = '',
}: BadgeProps) {
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${variantStyles[variant]} ${className}`}
>
{children}
</span>
);
}
+49
View File
@@ -0,0 +1,49 @@
'use client';
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
children: React.ReactNode;
}
export default function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled,
children,
className = '',
...props
}: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-xl transition-all disabled:opacity-60 disabled:cursor-not-allowed';
const variantStyles = {
primary: 'bg-[#4F46E5] hover:bg-[#4338CA] text-white',
secondary: 'bg-[#8a4cfc] hover:bg-[#712AE2] text-white',
outline: 'border border-[#4F46E5] text-[#4F46E5] hover:bg-[#4F46E5]/10',
ghost: 'text-text-main hover:bg-[#F3F4F6]',
danger: 'bg-[#EF4444] hover:bg-[#DC2626] text-white',
};
const sizeStyles = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-5 py-2.5 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading && (
<span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin mr-2" />
)}
{children}
</button>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
hover?: boolean;
}
export default function Card({
children,
className = '',
hover = false,
}: CardProps) {
return (
<div
className={`bg-white rounded-2xl border border-[#E5E7EB] p-6 ${
hover ? 'hover:shadow-float transition-shadow' : 'shadow-card'
} ${className}`}
>
{children}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
'use client';
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
icon?: React.ReactNode;
}
export default function Input({
label,
error,
icon,
className = '',
...props
}: InputProps) {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-text-main mb-1.5">
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">
{icon}
</div>
)}
<input
className={`w-full px-4 py-2.5 rounded-xl border bg-white text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-[#4F46E5] focus:border-transparent transition-all ${
icon ? 'pl-10' : ''
} ${
error
? 'border-[#EF4444] focus:ring-[#EF4444]'
: 'border-[#E5E7EB]'
} ${className}`}
{...props}
/>
</div>
{error && (
<p className="mt-1 text-xs text-[#EF4444]">{error}</p>
)}
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Badge } from './Badge';
export { default as Card } from './Card';
+31
View File
@@ -0,0 +1,31 @@
import { useState, useEffect } from 'react';
import commonCodeService from '@/services/commonCodeService';
interface CodeItem {
codeValue: string;
codeName: string;
sortOrder: number;
}
export function useCommonCodes(groupCode: string): { codes: CodeItem[]; loading: boolean } {
const [codes, setCodes] = useState<CodeItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
commonCodeService.getByGroup(groupCode)
.then((res) => {
if (res.success && res.data) {
setCodes(res.data.map((c: any) => ({
codeValue: c.codeValue,
codeName: c.codeName,
sortOrder: c.sortOrder,
})));
}
})
.catch(() => {})
.finally(() => setLoading(false));
}, [groupCode]);
return { codes, loading };
}
+13
View File
@@ -0,0 +1,13 @@
export function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
}
export function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
export function removeCookie(name: string) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax`;
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
export function middleware() {
return NextResponse.next();
}
export const config = {
matcher: ['/api/:path*'],
};
+5
View File
@@ -0,0 +1,5 @@
export const assignments = [
{ assignmentId: 1, equipmentId: 1, userId: 3, userName: '김현장', assignedDate: '2026-02-01', returnedDate: null, condOnAssign: '미개봉 신품', condOnReturn: null },
{ assignmentId: 2, equipmentId: 3, userId: 4, userName: '박파견', assignedDate: '2026-02-15', returnedDate: null, condOnAssign: '신품', condOnReturn: null },
{ assignmentId: 3, equipmentId: 6, userId: 5, userName: '이전문', assignedDate: '2026-01-15', returnedDate: null, condOnAssign: '미개봉 신품', condOnReturn: null },
];
+7
View File
@@ -0,0 +1,7 @@
export const comments = [
{ commentId: 1, ticketId: 1, userId: 3, userName: '김현장', commentText: '오라클 실행 계획 첨부합니다. Full Scan 발생 구간 확인 부탁드립니다.', attachedFileUrl: '/mock/exec_plan.png', createdAt: '2026-05-20T09:30:00Z' },
{ commentId: 2, ticketId: 1, userId: 5, userName: '이전문 (본사)', commentText: '인덱스 힌트 사용해보세요: /*+ INDEX(emp IDX_EMP_DEPT) */\n통계 정보 갱신도 필요해 보입니다.', attachedFileUrl: null, createdAt: '2026-05-20T10:15:00Z' },
{ commentId: 3, ticketId: 1, userId: 3, userName: '김현장', commentText: '힌트 적용 후 3초→0.5초로 개선되었습니다! ✅ 감사합니다.', attachedFileUrl: null, createdAt: '2026-05-20T11:00:00Z' },
{ commentId: 4, ticketId: 3, userId: 6, userName: '최개발', commentText: '빌드 로그 첨부합니다. Dockerfile 45번째 라인 에러.', attachedFileUrl: '/mock/build_log.txt', createdAt: '2026-05-18T11:30:00Z' },
{ commentId: 5, ticketId: 3, userId: 5, userName: '이전문 (본사)', commentText: 'Node 버전 mismatch 문제로 확인됩니다. .nvmrc 파일 확인해보세요.', attachedFileUrl: null, createdAt: '2026-05-18T14:00:00Z' },
];
+16
View File
@@ -0,0 +1,16 @@
export const commonCodes = [
{ codeId: 1, groupCode: 'EMP_ROLE', codeValue: 'ADMIN', codeName: '최고관리자', sortOrder: 1, isActive: 'Y' },
{ codeId: 2, groupCode: 'EMP_ROLE', codeValue: 'MANAGER', codeName: '본사관리자', sortOrder: 2, isActive: 'Y' },
{ codeId: 3, groupCode: 'EMP_ROLE', codeValue: 'EMPLOYEE', codeName: '파견직원', sortOrder: 3, isActive: 'Y' },
{ codeId: 4, groupCode: 'EMP_STATUS', codeValue: 'ACTIVE', codeName: '재직', sortOrder: 1, isActive: 'Y' },
{ codeId: 5, groupCode: 'EMP_STATUS', codeValue: 'ON_LEAVE', codeName: '휴직', sortOrder: 2, isActive: 'Y' },
{ codeId: 6, groupCode: 'EMP_STATUS', codeValue: 'RESIGNED', codeName: '퇴사', sortOrder: 3, isActive: 'Y' },
{ codeId: 7, groupCode: 'TICKET_CATEGORY', codeValue: 'BUG', codeName: '버그', sortOrder: 1, isActive: 'Y' },
{ codeId: 8, groupCode: 'TICKET_CATEGORY', codeValue: 'FEATURE', codeName: '기능', sortOrder: 2, isActive: 'Y' },
{ codeId: 9, groupCode: 'TICKET_CATEGORY', codeValue: 'SUPPORT', codeName: '지원', sortOrder: 3, isActive: 'Y' },
{ codeId: 10, groupCode: 'TICKET_CATEGORY', codeValue: 'QA', codeName: '품질', sortOrder: 4, isActive: 'Y' },
{ codeId: 11, groupCode: 'EQUIP_TYPE', codeValue: 'LAPTOP', codeName: '노트북', sortOrder: 1, isActive: 'Y' },
{ codeId: 12, groupCode: 'EQUIP_TYPE', codeValue: 'MONITOR', codeName: '모니터', sortOrder: 2, isActive: 'Y' },
{ codeId: 13, groupCode: 'EQUIP_TYPE', codeValue: 'TEST_DEVICE', codeName: '테스트장비', sortOrder: 3, isActive: 'Y' },
{ codeId: 14, groupCode: 'EQUIP_TYPE', codeValue: 'ACCESSORY', codeName: '액세서리', sortOrder: 4, isActive: 'Y' },
];
+4
View File
@@ -0,0 +1,4 @@
export const dispatchHistories = [
{ dispatchId: 1, empId: 3, projectId: 1, startDate: '2025-01-01', endDate: null, roleTitle: 'BACKEND', contributionRate: 100, individualTech: 'Java 17, Spring Boot, Oracle', performedTasks: '차세대 결제 시스템 API 개발, 성능 튜닝', achievements: '결제 처리 시간 40% 개선', clientFeedback: null, internalRating: null },
{ dispatchId: 2, empId: 4, projectId: 2, startDate: '2024-06-01', endDate: null, roleTitle: 'DBA', contributionRate: 100, individualTech: 'Oracle 11g, SQL Tuning, DataGuard', performedTasks: '운영 DB 모니터링, 장애 대응, 쿼리 튜닝', achievements: '월간 장애 건수 60% 감소', clientFeedback: null, internalRating: null },
];
+9
View File
@@ -0,0 +1,9 @@
export const employees = [
{ empId: 1, loginId: 'admin', name: '김관리', email: 'admin@hq.com', roleCode: 'ADMIN' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'WAITING' as const, joinedDate: '2020-01-01', leftDate: null },
{ empId: 2, loginId: 'manager1', name: '이매니저', email: 'manager@hq.com', roleCode: 'MANAGER' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'WAITING' as const, joinedDate: '2021-03-15', leftDate: null },
{ empId: 3, loginId: 'kim', name: '김현장', email: 'kim@client.com', roleCode: 'EMPLOYEE' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'DISPATCHED' as const, joinedDate: '2022-06-01', leftDate: null },
{ empId: 4, loginId: 'park', name: '박파견', email: 'park@client.com', roleCode: 'EMPLOYEE' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'DISPATCHED' as const, joinedDate: '2023-02-10', leftDate: null },
{ empId: 5, loginId: 'lee', name: '이전문', email: 'lee@hq.com', roleCode: 'MANAGER' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'WAITING' as const, joinedDate: '2019-11-20', leftDate: null },
{ empId: 6, loginId: 'choi', name: '최개발', email: 'choi@client.com', roleCode: 'EMPLOYEE' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'DISPATCHED' as const, joinedDate: '2023-08-05', leftDate: null },
{ empId: 7, loginId: 'jung', name: '정휴직', email: 'jung@hq.com', roleCode: 'EMPLOYEE' as const, empStatus: 'ON_LEAVE' as const, dispatchStatus: 'WAITING' as const, joinedDate: '2021-07-01', leftDate: null },
];
+8
View File
@@ -0,0 +1,8 @@
export const equipment = [
{ equipmentId: 1, equipmentType: 'LAPTOP' as const, manufacturer: 'Apple', modelName: 'MacBook Pro 16 M3', serialNumber: 'F2G8H3J9K1', assetCode: 'HQ-LP-2026-0001', specifications: { cpu: 'M3 Pro', ram: '36GB', ssd: '512GB' }, status: 'IN_USE' as const, purchaseDate: '2026-01-15', warrantyExpDate: '2028-01-15', version: 0 },
{ equipmentId: 2, equipmentType: 'LAPTOP' as const, manufacturer: 'Apple', modelName: 'MacBook Pro 14 M3', serialNumber: 'A1B2C3D4E5', assetCode: 'HQ-LP-2026-0002', specifications: { cpu: 'M3', ram: '18GB', ssd: '512GB' }, status: 'AVAILABLE' as const, purchaseDate: '2026-02-01', warrantyExpDate: '2028-02-01', version: 0 },
{ equipmentId: 3, equipmentType: 'MONITOR' as const, manufacturer: 'LG', modelName: '27UN880 Ergo', serialNumber: 'LG-27U-001', assetCode: 'HQ-MO-2026-0001', specifications: { size: '27"', resolution: '4K UHD', panel: 'IPS' }, status: 'IN_USE' as const, purchaseDate: '2026-01-20', warrantyExpDate: '2028-01-20', version: 0 },
{ equipmentId: 4, equipmentType: 'LAPTOP' as const, manufacturer: 'Samsung', modelName: 'Galaxy Book4 Pro', serialNumber: 'SAMSUNG-001', assetCode: 'HQ-LP-2026-0003', specifications: { cpu: 'Ultra 7', ram: '16GB', ssd: '256GB' }, status: 'REPAIRING' as const, purchaseDate: '2026-03-01', warrantyExpDate: '2028-03-01', version: 0 },
{ equipmentId: 5, equipmentType: 'MONITOR' as const, manufacturer: 'Dell', modelName: 'U2723QE', serialNumber: 'DELL-U27-002', assetCode: 'HQ-MO-2026-0002', specifications: { size: '27"', resolution: '4K', panel: 'IPS Black' }, status: 'AVAILABLE' as const, purchaseDate: '2026-04-01', warrantyExpDate: '2028-04-01', version: 0 },
{ equipmentId: 6, equipmentType: 'ACCESSORY' as const, manufacturer: 'Logitech', modelName: 'MX Master 3S', serialNumber: 'LOG-MX-001', assetCode: 'HQ-AC-2026-0001', specifications: { type: 'Mouse', connection: 'Bluetooth' }, status: 'IN_USE' as const, purchaseDate: '2026-01-10', warrantyExpDate: '2027-01-10', version: 0 },
];
+3
View File
@@ -0,0 +1,3 @@
export const maintenances = [
{ maintenanceId: 1, equipmentId: 4, reportedBy: 3, maintenanceType: 'REPAIR' as const, issueDescription: '키보드 침수 작동 불가', actionTaken: '공식 서비스센터 키보드 상판 교체 완료', cost: 250000, reportedDate: '2026-05-10', resolvedDate: '2026-05-15' },
];
+6
View File
@@ -0,0 +1,6 @@
export const notifications = [
{ notificationId: 1, type: 'TICKET', title: 'SR 티켓 배정됨', message: 'SR-2026-003 CI/CD 배포 실패 건이 이전문 매니저에게 배정되었습니다.', isRead: false, createdAt: '2026-05-21T09:00:00Z' },
{ notificationId: 2, type: 'REPORT', title: '보고서 반려', message: '주간 보고서(WR-2026-05-W1)가 반려되었습니다. 피드백을 확인해주세요.', isRead: false, createdAt: '2026-05-20T14:00:00Z' },
{ notificationId: 3, type: 'TICKET', title: 'SLA 임박 ⚠️', message: 'SR-2026-001 JVM 메모리 릭 건 SLA 데드라인이 4시간 남았습니다.', isRead: false, createdAt: '2026-05-21T07:00:00Z' },
{ notificationId: 4, type: 'EQUIPMENT', title: '장비 수리 완료', message: 'Galaxy Book4 Pro 키보드 수리가 완료되어 창고 입고되었습니다.', isRead: true, createdAt: '2026-05-19T16:00:00Z' },
];
+5
View File
@@ -0,0 +1,5 @@
export const projects = [
{ projectId: 1, projectType: 'SI' as const, projectName: '삼성페이 고도화 (SI)', contractCompany: '삼성전자', endUserCompany: '삼성전자', contractType: 'TURN_KEY' as const, status: 'ONGOING' as const, startDate: '2025-01-01', endDate: '2026-12-31', workLocationType: 'ON_SITE' as const, workAddress: '서울시 서초구', techEnvironment: 'Java 17, Spring Boot, Oracle 19c', mainTaskDesc: '삼성페이 차세대 시스템 구축', internalManagerId: 1 },
{ projectId: 2, projectType: 'SM' as const, projectName: 'LG CNS SM 운영', contractCompany: 'LG CNS', endUserCompany: 'LG CNS', contractType: 'MM' as const, status: 'ONGOING' as const, startDate: '2024-01-01', endDate: '2026-06-30', workLocationType: 'HYBRID' as const, workAddress: '서울시 강남구', techEnvironment: 'Java 11, Spring, Oracle 11g', mainTaskDesc: 'LG CNS 시스템 유지보수', internalManagerId: 2 },
{ projectId: 3, projectType: 'SM' as const, projectName: 'Kakao Maintenance', contractCompany: '카카오', endUserCompany: '카카오', contractType: 'MM' as const, status: 'ONGOING' as const, startDate: '2024-06-01', endDate: '2025-12-31', workLocationType: 'REMOTE' as const, workAddress: '경기도 판교', techEnvironment: 'Kotlin, Spring Cloud, MySQL', mainTaskDesc: '카카오 서비스 운영 유지보수', internalManagerId: 2 },
];
+5
View File
@@ -0,0 +1,5 @@
export const reports = [
{ reportId: 1, dispatchId: 1, userId: 3, userName: '김현장', reportType: 'WEEKLY' as const, targetDate: '2026-05-15', completedTasks: 'SR 3건 해결 완료 (DB튜닝, 배포파이프라인, 모니터링)', plannedTasks: '차주 성능 테스트 진행, 인덱스 리뷰', issuesAndRisks: '납기 일정 협의 필요', supportRequested: null, status: 'APPROVED' as const, reviewerId: 2, reviewerName: '이매니저', reviewComment: '수고하셨습니다.', createdAt: '2026-05-15T10:00:00Z', updatedAt: '2026-05-16T09:00:00Z' },
{ reportId: 2, dispatchId: 1, userId: 3, userName: '김현장', reportType: 'WEEKLY' as const, targetDate: '2026-05-22', completedTasks: '오라클 튜닝 2건, 장애 대응 1건', plannedTasks: 'DBA 정기 점검, 교육 수강', issuesAndRisks: null, supportRequested: null, status: 'SUBMITTED' as const, reviewerId: null, reviewerName: null, reviewComment: null, createdAt: '2026-05-22T10:00:00Z', updatedAt: '2026-05-22T10:00:00Z' },
{ reportId: 3, dispatchId: 2, userId: 4, userName: '박파견', reportType: 'WEEKLY' as const, targetDate: '2026-05-08', completedTasks: 'DB 모니터링, 장애 대응 2건', plannedTasks: '인덱스 튜닝 작업 예정', issuesAndRisks: '장비 SSD 불량 교체 필요', supportRequested: '노트북 SSD 교체 요청', status: 'REJECTED' as const, reviewerId: 2, reviewerName: '이매니저', reviewComment: '완료 실적에 SR 연동 내용 상세 보강 요망', createdAt: '2026-05-08T09:00:00Z', updatedAt: '2026-05-09T14:00:00Z' },
];
+9
View File
@@ -0,0 +1,9 @@
export const tickets = [
{ ticketId: 1, projectId: 1, requesterId: 3, requesterName: '김현장', assigneeId: null, assigneeName: null, category: 'BUG' as const, priority: 'CRITICAL' as const, title: 'JVM 메모리 릭 및 Heap Dump 분석 요청', description: 'Prod 서비스 JVM 메모리 사용량 지속 증가, Full GC 빈도 높음', status: 'OPEN' as const, dueDate: '2026-05-22', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-20T09:00:00Z', updatedAt: '2026-05-20T09:00:00Z' },
{ ticketId: 2, projectId: 2, requesterId: 4, requesterName: '박파견', assigneeId: null, assigneeName: null, category: 'SUPPORT' as const, priority: 'HIGH' as const, title: '사내망 방화벽 정책 가이드 필요', description: '파견지 사내망 연동을 위한 방화벽 정책 가이드 요청', status: 'OPEN' as const, dueDate: '2026-05-25', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-19T14:00:00Z', updatedAt: '2026-05-19T14:00:00Z' },
{ ticketId: 3, projectId: 1, requesterId: 6, requesterName: '최개발', assigneeId: 5, assigneeName: '이전문', category: 'BUG' as const, priority: 'CRITICAL' as const, title: 'CI/CD 파이프라인 staging 배포 실패', description: 'Staging 환경 배포 시 Docker build 실패', status: 'ASSIGNED' as const, dueDate: '2026-05-22', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-18T11:00:00Z', updatedAt: '2026-05-19T09:00:00Z' },
{ ticketId: 4, projectId: 3, requesterId: 4, requesterName: '박파견', assigneeId: 2, assigneeName: '이매니저', category: 'SUPPORT' as const, priority: 'NORMAL' as const, title: 'MacBook Pro 배터리 교체 요청', description: '배터리 성능 78%로 교체 주기 도래', status: 'IN_PROGRESS' as const, dueDate: '2026-05-30', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-17T10:00:00Z', updatedAt: '2026-05-18T15:00:00Z' },
{ ticketId: 5, projectId: 2, requesterId: 3, requesterName: '김현장', assigneeId: 5, assigneeName: '이전문', category: 'SUPPORT' as const, priority: 'HIGH' as const, title: 'VPN 접속 연장 요청', description: '원격 접속 VPN 인증서 만료 예정', status: 'RESOLVED' as const, dueDate: '2026-05-20', resolvedAt: '2026-05-19T17:00:00Z', resolutionNote: 'VPN 인증서 재발급 완료', slaWarnFlag: 'N' as const, createdAt: '2026-05-16T08:00:00Z', updatedAt: '2026-05-19T17:00:00Z' },
{ ticketId: 6, projectId: 1, requesterId: 6, requesterName: '최개발', assigneeId: 5, assigneeName: '이전문', category: 'BUG' as const, priority: 'HIGH' as const, title: 'DB 커넥션 풀 고갈 문제 분석', description: '특정 시간대에 DB 커넥션 풀이 고갈되는 현상', status: 'CLOSED' as const, dueDate: '2026-05-18', resolvedAt: '2026-05-17T12:00:00Z', resolutionNote: 'Connection leak, 코드 수정 완료', slaWarnFlag: 'N' as const, createdAt: '2026-05-15T09:00:00Z', updatedAt: '2026-05-17T12:00:00Z' },
{ ticketId: 7, projectId: 1, requesterId: 3, requesterName: '김현장', assigneeId: null, assigneeName: null, category: 'QA' as const, priority: 'HIGH' as const, title: '오라클 쿼리 튜닝 필요 (Full Scan)', description: '실행 계획 상 Full Scan 발생, 인덱스 튜닝 필요', status: 'OPEN' as const, dueDate: '2026-05-28', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-21T08:00:00Z', updatedAt: '2026-05-21T08:00:00Z' },
];
+8
View File
@@ -0,0 +1,8 @@
export const training = [
{ progressId: 1, userId: 3, courseName: 'Next.js Enterprise Architecture', progressRate: 75, status: 'ONGOING' as const, updatedAt: '2026-05-15T10:00:00Z' },
{ progressId: 2, userId: 3, courseName: 'SpringBoot JPA Masterclass', progressRate: 100, status: 'COMPLETED' as const, updatedAt: '2026-04-20T10:00:00Z' },
{ progressId: 3, userId: 3, courseName: 'Oracle SQL Tuning & Optimization', progressRate: 30, status: 'ONGOING' as const, updatedAt: '2026-05-18T10:00:00Z' },
{ progressId: 4, userId: 3, courseName: 'Kubernetes for DevOps Engineers', progressRate: 0, status: 'NOT_STARTED' as const, updatedAt: '2026-05-01T10:00:00Z' },
{ progressId: 5, userId: 3, courseName: 'React 19 Advanced Patterns', progressRate: 100, status: 'COMPLETED' as const, updatedAt: '2026-03-15T10:00:00Z' },
{ progressId: 6, userId: 3, courseName: 'Cloud Architecture on AWS', progressRate: 0, status: 'NOT_STARTED' as const, updatedAt: '2026-05-01T10:00:00Z' },
];
+535
View File
@@ -0,0 +1,535 @@
import MockAdapter from 'axios-mock-adapter';
import type { AxiosInstance } from 'axios';
import type { RoleCode } from '@/types';
import { employees as _employees } from './data/employees';
import { projects as _projects } from './data/projects';
import { dispatchHistories as _dispatchHistories } from './data/dispatchHistories';
import { tickets as _tickets } from './data/tickets';
import { comments as _comments } from './data/comments';
import { equipment as _equipment } from './data/equipment';
import { assignments as _assignments } from './data/assignments';
import { maintenances as _maintenances } from './data/maintenances';
import { reports as _reports } from './data/reports';
import { training as _training } from './data/training';
import { notifications as _notifications } from './data/notifications';
import { commonCodes as _commonCodes } from './data/commonCodes';
interface MockUser {
loginId: string;
password: string;
name: string;
role: RoleCode;
}
const MOCK_USERS: MockUser[] = [
{ loginId: 'admin', password: 'admin123', name: 'Admin', role: 'ADMIN' },
{ loginId: 'manager', password: 'manager123', name: 'Manager Kim', role: 'MANAGER' },
{ loginId: 'employee', password: 'emp123', name: 'Employee Lee', role: 'EMPLOYEE' },
];
function paginate<T>(arr: T[], page = 0, size = 20) {
const start = page * size;
const content = arr.slice(start, start + size);
return { content, totalElements: arr.length, totalPages: Math.ceil(arr.length / size), number: page, size };
}
let initialized = false;
export function setupMocks(instance: AxiosInstance) {
if (typeof window === 'undefined' || initialized) return;
initialized = true;
const mock = new MockAdapter(instance, { delayResponse: 200 });
const s: Record<string, any[]> = {
employees: _employees,
projects: _projects,
dispatchHistories: _dispatchHistories,
tickets: _tickets,
comments: _comments,
equipment: _equipment,
assignments: _assignments,
maintenances: _maintenances,
reports: _reports,
training: _training,
notifications: _notifications,
commonCodes: _commonCodes,
};
/* Auth */
mock.onPost('/auth/login').reply((config) => {
const { loginId, password } = JSON.parse(config.data);
const mockUser = MOCK_USERS.find((u) => u.loginId === loginId && u.password === password);
if (!mockUser) {
return [401, { success: false, message: '아이디 또는 비밀번호가 일치하지 않습니다.', errorCode: 'AUTH_001' }];
}
const emp = s.employees.find((e: any) => e.loginId === loginId) || s.employees[0];
const accessToken = `mock-jwt-${mockUser.loginId}-${Date.now()}`;
const refreshToken = `mock-refresh-${mockUser.loginId}-${Date.now()}`;
return [200, { success: true, message: '로그인 성공', data: { accessToken, refreshToken, employee: emp } }];
});
mock.onPost('/auth/refresh').reply((config) => {
const { refreshToken } = JSON.parse(config.data);
if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) {
return [401, { success: false, message: '유효하지 않은 리프레시 토큰입니다.', errorCode: 'AUTH_002' }];
}
const loginId = refreshToken.split('-')[2];
const emp = s.employees.find((e: any) => e.loginId === loginId) || s.employees[0];
const accessToken = `mock-jwt-${loginId}-${Date.now()}`;
const newRefreshToken = `mock-refresh-${loginId}-${Date.now()}`;
return [200, { success: true, message: '토큰 갱신 성공', data: { accessToken, refreshToken: newRefreshToken, employee: emp } }];
});
mock.onGet('/employees/me').reply((config) => {
const authHeader = config.headers?.Authorization;
if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
const token = authHeader.replace('Bearer ', '');
const loginId = token.split('-')[2];
const emp = s.employees.find((e: any) => e.loginId === loginId);
if (emp) return [200, { success: true, message: 'OK', data: emp }];
}
return [200, { success: true, message: 'OK', data: s.employees[0] }];
});
mock.onGet('/employees').reply((config) => [200, { success: true, message: 'OK', data: paginate(s.employees, config.params?.page, config.params?.size) }]);
mock.onGet(/\/employees\/search/).reply((config) => {
const keyword = (config.params?.keyword || '').toLowerCase();
const filtered = s.employees.filter((e: any) => e.name.toLowerCase().includes(keyword) || e.email.toLowerCase().includes(keyword));
return [200, { success: true, message: 'OK', data: filtered.map((e: any) => ({ empId: e.empId, name: e.name, roleCode: e.roleCode, dispatchStatus: e.dispatchStatus, email: e.email })) }];
});
mock.onGet(/\/employees\/\d+$/).reply((config) => {
const id = parseInt(config.url?.match(/\/employees\/(\d+)$/)?.[1] || '0');
const emp = s.employees.find((e: any) => e.empId === id);
if (!emp) return [404, { success: false, message: 'Not found' }];
return [200, { success: true, message: 'OK', data: emp }];
});
mock.onGet(/\/employees\/\d+\/dispatch-histories/).reply(() => [200, { success: true, message: 'OK', data: s.dispatchHistories }]);
/* Tickets */
mock.onGet('/tickets').reply((config) => {
let result = [...s.tickets];
const { category, priority, status, searchKeyword } = config.params || {};
if (category && category !== 'ALL') result = result.filter((t: any) => t.category === category);
if (priority && priority !== 'ALL') result = result.filter((t: any) => t.priority === priority);
if (status && status !== 'ALL') result = result.filter((t: any) => t.status === status);
if (searchKeyword) result = result.filter((t: any) => t.title.toLowerCase().includes(searchKeyword.toLowerCase()));
return [200, { success: true, message: 'OK', data: paginate(result, config.params?.page, config.params?.size) }];
});
mock.onGet(/\/tickets\/(\d+)$/).reply((config) => {
const id = parseInt(config.url?.match(/\/tickets\/(\d+)$/)?.[1] || '0');
const ticket = s.tickets.find((t: any) => t.ticketId === id);
if (!ticket) return [404, { success: false, message: 'Not found' }];
ticket.isImminent = ticket.dueDate ? new Date(ticket.dueDate).getTime() - Date.now() < 86400000 : false;
return [200, { success: true, message: 'OK', data: ticket }];
});
mock.onPost(/\/tickets\/(\d+)\/comments/).reply((config) => {
const ticketId = parseInt(config.url?.match(/\/tickets\/(\d+)\/comments/)?.[1] || '0');
const { commentText, attachedFileUrl } = JSON.parse(config.data);
const newComment = { commentId: Date.now(), ticketId, userId: 1, userName: 'Admin User (본사)', commentText, attachedFileUrl: attachedFileUrl || null, createdAt: new Date().toISOString() };
return [201, { success: true, message: '등록 완료', data: newComment }];
});
mock.onGet(/\/tickets\/(\d+)\/comments/).reply((config) => {
const ticketId = parseInt(config.url?.match(/\/tickets\/(\d+)\/comments/)?.[1] || '0');
return [200, { success: true, message: 'OK', data: s.comments.filter((c: any) => c.ticketId === ticketId) }];
});
mock.onPut(/\/tickets\/(\d+)\/status/).reply((config) => {
const id = parseInt(config.url?.match(/\/tickets\/(\d+)\/status/)?.[1] || '0');
const ticket = s.tickets.find((t: any) => t.ticketId === id);
if (ticket) ticket.status = config.params?.status || ticket.status;
return [200, { success: true, message: '상태 변경 완료', data: ticket }];
});
mock.onPut(/\/tickets\/(\d+)\/resolve/).reply((config) => {
const id = parseInt(config.url?.match(/\/tickets\/(\d+)\/resolve/)?.[1] || '0');
const ticket = s.tickets.find((t: any) => t.ticketId === id);
if (ticket) { ticket.status = 'RESOLVED'; ticket.resolutionNote = JSON.parse(config.data).resolutionNote; ticket.resolvedAt = new Date().toISOString(); }
return [200, { success: true, message: '해결 처리 완료', data: ticket }];
});
mock.onDelete(/\/tickets\/(\d+)$/).reply(204);
/* Equipment */
mock.onGet('/equipment').reply((config) => {
let result = [...s.equipment];
const { type, status, manufacturer } = config.params || {};
if (type) result = result.filter((e: any) => e.equipmentType === type);
if (status) result = result.filter((e: any) => e.status === status);
if (manufacturer) result = result.filter((e: any) => e.manufacturer === manufacturer);
return [200, { success: true, message: 'OK', data: paginate(result, config.params?.page, config.params?.size) }];
});
mock.onGet(/\/equipment\/(\d+)$/).reply((config) => {
const id = parseInt(config.url?.match(/\/equipment\/(\d+)$/)?.[1] || '0');
const equip = s.equipment.find((e: any) => e.equipmentId === id);
if (!equip) return [404, { success: false, message: 'Not found' }];
return [200, { success: true, message: 'OK', data: equip }];
});
mock.onPost('/equipment').reply((config) => {
const body = JSON.parse(config.data);
const newEquip = { equipmentId: Date.now(), ...body, version: 0 };
s.equipment.push(newEquip);
return [201, { success: true, message: '등록 완료', data: newEquip }];
});
mock.onPost(/\/equipment\/(\d+)\/assign/).reply((config) => {
const equipId = parseInt(config.url?.match(/\/equipment\/(\d+)\/assign/)?.[1] || '0');
const { assigneeId, conditionNote } = JSON.parse(config.data);
const equip = s.equipment.find((e: any) => e.equipmentId === equipId);
if (!equip || equip.status !== 'AVAILABLE') return [400, { success: false, message: '지급 가능한 재고 상태가 아닙니다.', errorCode: 'EQP_ERR_001' }];
equip.status = 'IN_USE';
const assignment = { assignmentId: Date.now(), equipmentId: equipId, userId: assigneeId, userName: s.employees.find((e: any) => e.empId === assigneeId)?.name || '', assignedDate: new Date().toISOString().split('T')[0], returnedDate: null, condOnAssign: conditionNote, condOnReturn: null };
s.assignments.push(assignment);
return [200, { success: true, message: '장비 지급 완료', data: assignment }];
});
mock.onPut(/\/equipment\/(\d+)\/return/).reply((config) => {
const equipId = parseInt(config.url?.match(/\/equipment\/(\d+)\/return/)?.[1] || '0');
const { conditionNote } = JSON.parse(config.data);
const equip = s.equipment.find((e: any) => e.equipmentId === equipId);
if (equip) equip.status = 'AVAILABLE';
const active = s.assignments.find((a: any) => a.equipmentId === equipId && !a.returnedDate);
if (active) { active.returnedDate = new Date().toISOString().split('T')[0]; active.condOnReturn = conditionNote; }
return [200, { success: true, message: '반납 완료', data: active }];
});
mock.onGet('/equipment/summary').reply(() => {
const counts: Record<string, number> = {};
s.equipment.forEach((e: any) => { counts[e.status] = (counts[e.status] || 0) + 1; });
counts.ALL = Object.values(counts).reduce((a: number, b: any) => a + (typeof b === 'number' ? b : 0), 0);
return [200, { success: true, message: 'OK', data: { counts } }];
});
/* Reports */
mock.onGet('/reports').reply((config) => {
let result = [...s.reports];
const { reportType, status } = config.params || {};
if (reportType) result = result.filter((r: any) => r.reportType === reportType);
if (status) result = result.filter((r: any) => r.status === status);
return [200, { success: true, message: 'OK', data: paginate(result, config.params?.page, config.params?.size) }];
});
mock.onPost('/reports').reply((config) => {
const body = JSON.parse(config.data);
const newReport = { reportId: Date.now(), ...body, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
s.reports.push(newReport);
return [201, { success: true, message: '보고서 생성 완료', data: newReport }];
});
mock.onPut(/\/reports\/(\d+)\/review/).reply((config) => {
const id = parseInt(config.url?.match(/\/reports\/(\d+)\/review/)?.[1] || '0');
const { status, reviewComment } = JSON.parse(config.data);
const report = s.reports.find((r: any) => r.reportId === id);
if (report) { report.status = status; report.reviewComment = reviewComment || null; }
return [200, { success: true, message: '결재 처리 완료', data: report }];
});
mock.onGet('/reports/sr-auto-complete').reply(() => [200, { success: true, message: 'OK', data: ['SR-2026-042: 오라클 인덱스 튜닝 완료', 'SR-2026-043: 배포 파이프라인 오류 수정'] }]);
/* Training */
mock.onGet('/training/me').reply(() => [200, { success: true, message: 'OK', data: s.training }]);
mock.onGet('/training/summary').reply(() => {
const byCourse: Record<string, any> = {};
s.training.forEach((t: any) => {
if (!byCourse[t.courseName]) byCourse[t.courseName] = { total: 0, completed: 0, totalRate: 0 };
byCourse[t.courseName].total++;
if (t.status === 'COMPLETED') byCourse[t.courseName].completed++;
byCourse[t.courseName].totalRate += t.progressRate;
});
const summary = Object.entries(byCourse).map(([courseName, v]) => ({ courseName, totalEnrolled: v.total, completedCount: v.completed, avgProgress: Math.round(v.totalRate / v.total) }));
return [200, { success: true, message: 'OK', data: summary }];
});
mock.onPut(/\/training\/(\d+)$/).reply((config) => {
const id = parseInt(config.url?.match(/\/training\/(\d+)/)?.[1] || '0');
const body = JSON.parse(config.data);
const prog = s.training.find((t: any) => t.progressId === id);
if (prog) { if (body.progressRate !== undefined) prog.progressRate = body.progressRate; if (body.status) prog.status = body.status; }
return [200, { success: true, message: 'OK', data: prog }];
});
/* Notifications */
mock.onGet('/notifications/unread').reply(() => [200, { success: true, message: 'OK', data: { count: s.notifications.filter((n: any) => !n.isRead).length } }]);
mock.onGet('/notifications').reply(() => [200, { success: true, message: 'OK', data: s.notifications }]);
mock.onPut(/\/notifications\/(\d+)\/read$/).reply((config) => {
const id = parseInt(config.url?.match(/\/notifications\/(\d+)\/read/)?.[1] || '0');
const n = s.notifications.find((x: any) => x.notificationId === id);
if (n) n.isRead = true;
return [200, { success: true, message: 'OK', data: null }];
});
/* Parking */
mock.onGet('/health').reply(() => {
const now = new Date();
const day = now.getDay();
const hour = now.getHours();
const parked = day === 0 || day === 6 || hour < 8 || hour >= 20;
return [200, { success: true, message: 'OK', data: { parked } }];
});
mock.onPost('/wake').reply(() => new Promise((resolve) => {
setTimeout(() => {
resolve([200, { success: true, message: '관리자에게 기동 요청이 전송되었습니다. iLO4 원격 부팅이 진행됩니다.', data: { success: true, message: '관리자에게 기동 요청이 전송되었습니다. iLO4 원격 부팅이 진행됩니다.', estimatedWakeTime: '약 5~10분 소요' } }]);
}, 3000);
}));
/* Projects */
mock.onGet('/projects').reply((config) => {
let result = [...s.projects];
const { status } = config.params || {};
if (status) result = result.filter((p: any) => p.status === status);
return [200, { success: true, message: 'OK', data: result }];
});
mock.onGet(/\/projects\/\d+/).reply((config) => {
const id = parseInt(config.url?.match(/\/projects\/(\d+)/)?.[1] || '0');
const project = s.projects.find((p: any) => p.projectId === id);
if (!project) return [404, { success: false, message: 'Not found' }];
return [200, { success: true, message: 'OK', data: project }];
});
mock.onPost('/projects').reply((config) => {
const body = JSON.parse(config.data);
const newProject = { projectId: Date.now(), ...body, createdAt: new Date().toISOString() };
s.projects.push(newProject);
return [201, { success: true, message: '프로젝트가 등록되었습니다', data: newProject }];
});
mock.onPut(/\/projects\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/projects\/(\d+)/)?.[1] || '0');
const body = JSON.parse(config.data);
const project = s.projects.find((p: any) => p.projectId === id);
if (!project) return [404, { success: false, message: 'Not found' }];
Object.assign(project, body);
return [200, { success: true, message: '프로젝트가 수정되었습니다', data: project }];
});
mock.onDelete(/\/projects\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/projects\/(\d+)/)?.[1] || '0');
const idx = s.projects.findIndex((p: any) => p.projectId === id);
if (idx === -1) return [404, { success: false, message: 'Not found' }];
s.projects.splice(idx, 1);
return [200, { success: true, message: '프로젝트가 삭제되었습니다', data: null }];
});
/* Common Codes */
mock.onGet('/common-codes').reply((config) => {
let result = [...s.commonCodes];
const { groupCode } = config.params || {};
if (groupCode) result = result.filter((c: any) => c.groupCode === groupCode);
return [200, { success: true, message: 'OK', data: result }];
});
mock.onGet('/common-codes/groups').reply(() => {
const groups = [...new Set(s.commonCodes.map((c: any) => c.groupCode))];
return [200, { success: true, message: 'OK', data: groups }];
});
mock.onPost('/common-codes').reply((config) => {
const body = JSON.parse(config.data);
const newCode = { codeId: Date.now(), ...body };
s.commonCodes.push(newCode);
return [200, { success: true, message: '생성 완료', data: newCode }];
});
mock.onPut(/\/common-codes\/\d+/).reply((config) => {
const id = parseInt(config.url?.match(/\/common-codes\/(\d+)/)?.[1] || '0');
const body = JSON.parse(config.data);
const idx = s.commonCodes.findIndex((c: any) => c.codeId === id);
if (idx === -1) return [404, { success: false, message: 'Not found' }];
s.commonCodes[idx] = { ...s.commonCodes[idx], ...body };
return [200, { success: true, message: '수정 완료', data: s.commonCodes[idx] }];
});
mock.onDelete(/\/common-codes\/\d+/).reply((config) => {
const id = parseInt(config.url?.match(/\/common-codes\/(\d+)/)?.[1] || '0');
const idx = s.commonCodes.findIndex((c: any) => c.codeId === id);
if (idx === -1) return [404, { success: false, message: 'Not found' }];
s.commonCodes.splice(idx, 1);
return [200, { success: true, message: '삭제 완료', data: null }];
});
/* Calendar Events */
mock.onGet('/calendar/events').reply(() => {
const events: any[] = [];
const now = new Date();
// Ticket due dates
s.tickets.forEach((t: any) => {
if (t.dueDate) events.push({ eventType: 'TICKET_DUE', sourceId: t.ticketId, title: t.title, eventDate: t.dueDate.split('T')[0], color: '#3B82F6', linkUrl: `/issues/${t.ticketId}` });
});
return [200, { success: true, message: 'OK', data: events }];
});
/* Employee CRUD */
mock.onPost('/employees').reply((config) => {
const body = JSON.parse(config.data);
const newEmp = { empId: Date.now(), ...body, password: 'encoded', createdAt: new Date().toISOString() };
s.employees.push(newEmp);
return [200, { success: true, message: '직원이 생성되었습니다', data: newEmp }];
});
mock.onPut(/\/employees\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/employees\/(\d+)/)?.[1] || '0');
const body = JSON.parse(config.data);
const emp = s.employees.find((e: any) => e.empId === id);
if (!emp) return [404, { success: false, message: 'Not found' }];
Object.assign(emp, body);
return [200, { success: true, message: '직원이 수정되었습니다', data: emp }];
});
mock.onDelete(/\/employees\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/employees\/(\d+)/)?.[1] || '0');
const idx = s.employees.findIndex((e: any) => e.empId === id);
if (idx === -1) return [404, { success: false, message: 'Not found' }];
s.employees.splice(idx, 1);
return [200, { success: true, message: '직원이 삭제되었습니다', data: null }];
});
mock.onPut('/employees/me/password').reply(() => [200, { success: true, message: '비밀번호가 변경되었습니다', data: null }]);
mock.onGet(/\/employees\/(\d+)\/portfolio/).reply((config) => {
const id = parseInt(config.url?.match(/\/employees\/(\d+)\/portfolio/)?.[1] || '0');
const emp = s.employees.find((e: any) => e.empId === id);
const dispatchList = s.dispatchHistories.filter((d: any) => d.empId === id);
const portfolio = dispatchList.map((d: any) => ({
dispatchHistory: d,
projectName: s.projects.find((p: any) => p.projectId === d.projectId)?.projectName || '',
reports: s.reports.filter((r: any) => r.dispatchId === d.dispatchId),
resolvedTickets: s.tickets.filter((t: any) => {
const project = s.projects.find((p: any) => p.projectId === d.projectId);
return project && t.projectId === project.projectId;
}),
}));
return [200, { success: true, message: 'OK', data: portfolio }];
});
mock.onPut(/\/employees\/(\d+)\/dispatch-histories\/(\d+)\/achievements/).reply(() => [200, { success: true, message: 'OK', data: null }]);
/* Dispatch History CRUD */
mock.onGet('/dispatch-histories').reply((config) => {
let result = [...s.dispatchHistories];
const { empId, projectId } = config.params || {};
if (empId) result = result.filter((d: any) => d.empId === Number(empId));
if (projectId) result = result.filter((d: any) => d.projectId === Number(projectId));
return [200, { success: true, message: 'OK', data: { content: result, totalElements: result.length, totalPages: 1, number: 0, size: result.length } }];
});
mock.onGet(/\/dispatch-histories\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/dispatch-histories\/(\d+)/)?.[1] || '0');
const dh = s.dispatchHistories.find((d: any) => d.dispatchId === id);
if (!dh) return [404, { success: false, message: 'Not found' }];
return [200, { success: true, message: 'OK', data: dh }];
});
mock.onGet('/dispatch-histories/active').reply(() => [200, { success: true, message: 'OK', data: s.dispatchHistories.filter((d: any) => !d.endDate) }]);
mock.onPut(/\/dispatch-histories\/(\d+)\/end/).reply((config) => {
const id = parseInt(config.url?.match(/\/dispatch-histories\/(\d+)\/end/)?.[1] || '0');
const dh = s.dispatchHistories.find((d: any) => d.dispatchId === id);
if (dh) dh.endDate = new Date().toISOString().split('T')[0];
return [200, { success: true, message: '파견이 종료되었습니다', data: dh }];
});
/* Tickets - additional */
mock.onPost('/tickets').reply((config) => {
const body = JSON.parse(config.data);
const newTicket = { ticketId: Date.now(), ...body, status: 'OPEN', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
s.tickets.push(newTicket);
return [200, { success: true, message: '티켓이 생성되었습니다', data: newTicket }];
});
mock.onDelete(/\/tickets\/(\d+)\/comments\/(\d+)/).reply(() => [200, { success: true, message: '삭제 완료', data: null }]);
/* Equipment - additional */
mock.onPut(/\/equipment\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/equipment\/(\d+)/)?.[1] || '0');
const body = JSON.parse(config.data);
const equip = s.equipment.find((e: any) => e.equipmentId === id);
if (!equip) return [404, { success: false, message: 'Not found' }];
Object.assign(equip, body);
return [200, { success: true, message: '수정 완료', data: equip }];
});
mock.onDelete(/\/equipment\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/equipment\/(\d+)/)?.[1] || '0');
const idx = s.equipment.findIndex((e: any) => e.equipmentId === id);
if (idx === -1) return [404, { success: false, message: 'Not found' }];
s.equipment.splice(idx, 1);
return [200, { success: true, message: '삭제 완료', data: null }];
});
mock.onGet(/\/equipment\/(\d+)\/assignments/).reply((config) => {
const id = parseInt(config.url?.match(/\/equipment\/(\d+)\/assignments/)?.[1] || '0');
return [200, { success: true, message: 'OK', data: s.assignments.filter((a: any) => a.equipmentId === id) }];
});
mock.onGet(/\/equipment\/(\d+)\/maintenances/).reply((config) => {
const id = parseInt(config.url?.match(/\/equipment\/(\d+)\/maintenances/)?.[1] || '0');
return [200, { success: true, message: 'OK', data: s.maintenances.filter((m: any) => m.equipmentId === id) }];
});
mock.onPost(/\/equipment\/(\d+)\/maintenance/).reply((config) => {
const id = parseInt(config.url?.match(/\/equipment\/(\d+)\/maintenance/)?.[1] || '0');
const body = JSON.parse(config.data);
const newMaint = { maintenanceId: Date.now(), equipmentId: id, ...body, reportedDate: new Date().toISOString().split('T')[0] };
s.maintenances.push(newMaint);
return [200, { success: true, message: '등록 완료', data: newMaint }];
});
/* Reports - additional */
mock.onGet(/\/reports\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/reports\/(\d+)/)?.[1] || '0');
const report = s.reports.find((r: any) => r.reportId === id);
if (!report) return [404, { success: false, message: 'Not found' }];
return [200, { success: true, message: 'OK', data: report }];
});
mock.onPut(/\/reports\/(\d+)\/status/).reply((config) => {
const id = parseInt(config.url?.match(/\/reports\/(\d+)\/status/)?.[1] || '0');
const { status } = config.params || {};
const report = s.reports.find((r: any) => r.reportId === id);
if (report) report.status = status || report.status;
return [200, { success: true, message: '상태 변경 완료', data: report }];
});
mock.onDelete(/\/reports\/(\d+)/).reply((config) => {
const id = parseInt(config.url?.match(/\/reports\/(\d+)/)?.[1] || '0');
const idx = s.reports.findIndex((r: any) => r.reportId === id);
if (idx === -1) return [404, { success: false, message: 'Not found' }];
s.reports.splice(idx, 1);
return [200, { success: true, message: '삭제 완료', data: null }];
});
mock.onGet('/reports/summary').reply(() => [200, { success: true, message: 'OK', data: { submitted: s.reports.filter((r: any) => r.status === 'SUBMITTED').length, approved: s.reports.filter((r: any) => r.status === 'APPROVED').length, rejected: s.reports.filter((r: any) => r.status === 'REJECTED').length, draft: s.reports.filter((r: any) => r.status === 'DRAFT').length } }]);
/* Projects - my */
mock.onGet('/projects/my').reply((config) => {
const empId = 3; // default employee
const dispatchProjects = s.dispatchHistories
.filter((d: any) => d.empId === empId)
.map((d: any) => s.projects.find((p: any) => p.projectId === d.projectId))
.filter(Boolean);
return [200, { success: true, message: 'OK', data: dispatchProjects }];
});
/* Training */
mock.onGet('/training').reply((config) => {
const { userId } = config.params || {};
let result = [...s.training];
if (userId) result = result.filter((t: any) => t.userId === Number(userId));
return [200, { success: true, message: 'OK', data: result }];
});
/* File upload */
mock.onPost('/files/upload').reply(() => [200, { success: true, message: '업로드 완료', data: { url: '/uploads/mock-file.png', name: 'mock-file.png' } }]);
mock.onAny().passThrough();
}
+83
View File
@@ -0,0 +1,83 @@
import axios, { type AxiosInstance, AxiosError } from 'axios';
import type { ApiResponse } from '@/types';
import { setCookie, removeCookie } from '@/lib/cookie';
import { setupMocks } from '@/mocks/handler';
interface TypedAxios extends AxiosInstance {
get<T>(url: string, config?: Record<string, unknown>): Promise<T>;
post<T>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<T>;
put<T>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<T>;
delete<T>(url: string, config?: Record<string, unknown>): Promise<T>;
}
interface AxiosRequestConfigWithSentinel {
sentinel?: boolean;
}
const instance = axios.create({
baseURL: '/api/v1',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
instance.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
instance.interceptors.response.use(
(res) => res.data,
async (err: AxiosError<ApiResponse<null>>) => {
const status = err.response?.status;
const originalRequest = err.config;
const configWithSentinel = originalRequest as typeof originalRequest & AxiosRequestConfigWithSentinel;
// Treat 401/403 as auth errors -> refresh or redirect to login
if ((status === 401 || status === 403) && typeof window !== 'undefined' && originalRequest && !configWithSentinel.sentinel) {
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
configWithSentinel.sentinel = true;
const res = await axios.post('/api/v1/auth/refresh', { refreshToken });
const { accessToken, refreshToken: newRefresh } = res.data.data;
localStorage.setItem('accessToken', accessToken);
if (newRefresh) localStorage.setItem('refreshToken', newRefresh);
setCookie('accessToken', accessToken);
configWithSentinel.headers.Authorization = `Bearer ${accessToken}`;
return instance(configWithSentinel);
}
} catch {
// refresh failed
}
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
removeCookie('accessToken');
window.location.href = '/login';
return Promise.reject({ errorCode: 'UNAUTHORIZED', message: '인증이 만료되었습니다.', status });
}
if ((status === 502 || status === 503) && typeof window !== 'undefined') {
import('@/stores').then(({ useParkingStore }) => {
useParkingStore.getState().checkParking();
});
}
const errorCode = err.response?.data?.errorCode;
const message = err.response?.data?.message || err.message;
return Promise.reject({ errorCode, message, status });
},
);
const apiClient = instance as TypedAxios;
// Mock — enabled for development (static import ensures synchronous setup)
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'production') {
setupMocks(instance);
}
export default apiClient;
+16
View File
@@ -0,0 +1,16 @@
import apiClient from './apiClient';
import type { ApiResponse, CommonCode } from '@/types';
const commonCodeService = {
getByGroup(groupCode: string) {
return apiClient.get<ApiResponse<CommonCode[]>>('/common-codes', {
params: { groupCode },
});
},
getAllGroups() {
return apiClient.get<ApiResponse<CommonCode[]>>('/common-codes');
},
};
export default commonCodeService;
+27
View File
@@ -0,0 +1,27 @@
import apiClient from './apiClient';
import type { ApiResponse, Page, DispatchHistory } from '@/types';
const dispatchHistoryService = {
getByEmployee: (empId: number) =>
apiClient.get<ApiResponse<DispatchHistory[]>>(`/dispatch-histories/employee/${empId}`),
getActive: () =>
apiClient.get<ApiResponse<DispatchHistory[]>>('/dispatch-histories/active'),
getAll: (params?: Record<string, unknown>) =>
apiClient.get<ApiResponse<Page<DispatchHistory>>>('/dispatch-histories', { params }),
getById: (id: number) =>
apiClient.get<ApiResponse<DispatchHistory>>(`/dispatch-histories/${id}`),
create: (data: Record<string, unknown>) =>
apiClient.post<ApiResponse<DispatchHistory>>('/dispatch-histories', data),
endDispatch: (id: number) =>
apiClient.put<ApiResponse<DispatchHistory>>(`/dispatch-histories/${id}/end`),
deleteDispatch: (id: number) =>
apiClient.delete<ApiResponse<null>>(`/dispatch-histories/${id}`),
};
export default dispatchHistoryService;
+97
View File
@@ -0,0 +1,97 @@
import apiClient from './apiClient';
import type { ApiResponse, Employee, EmployeeSearchResult, Page, DispatchHistory, Project } from '@/types';
const employeeService = {
login(credentials: { loginId: string; password: string }) {
return apiClient.post<ApiResponse<{ accessToken: string; refreshToken: string; employee: Employee }>>('/auth/login', credentials);
},
refresh(refreshToken: string) {
return apiClient.post<ApiResponse<{ accessToken: string; refreshToken: string; employee: Employee }>>('/auth/refresh', { refreshToken });
},
getAll(params?: { roleCode?: string; empStatus?: string; dispatchStatus?: string; page?: number; size?: number }) {
return apiClient.get<ApiResponse<Page<Employee>>>('/employees', { params });
},
getOne(empId: number) {
return apiClient.get<ApiResponse<Employee>>(`/employees/${empId}`);
},
getMe() {
return apiClient.get<ApiResponse<Employee>>('/employees/me');
},
search(keyword: string) {
return apiClient.get<ApiResponse<EmployeeSearchResult[]>>('/employees/search', {
params: { keyword },
});
},
create(data: Partial<Employee>) {
return apiClient.post<ApiResponse<Employee>>('/employees', data);
},
update(empId: number, data: Partial<Employee>) {
return apiClient.put<ApiResponse<Employee>>(`/employees/${empId}`, data);
},
delete(empId: number) {
return apiClient.delete<ApiResponse<void>>(`/employees/${empId}`);
},
getDispatchHistories(empId: number) {
return apiClient.get<ApiResponse<DispatchHistory[]>>(`/employees/${empId}/dispatch-histories`);
},
getPortfolio(empId: number) {
return apiClient.get<ApiResponse<PortfolioEntry[]>>(`/employees/${empId}/portfolio`);
},
updateAchievements(empId: number, dispatchId: number, achievements: string) {
return apiClient.put<ApiResponse<void>>(`/employees/${empId}/dispatch-histories/${dispatchId}/achievements`, { achievements });
},
createDispatch(data: CreateDispatchRequest) {
return apiClient.post<ApiResponse<DispatchHistory>>('/dispatch-histories', data);
},
endDispatch(dispatchId: number) {
return apiClient.put<ApiResponse<DispatchHistory>>(`/dispatch-histories/${dispatchId}/end`);
},
getActiveDispatches() {
return apiClient.get<ApiResponse<DispatchHistory[]>>('/dispatch-histories/active');
},
changePassword(currentPassword: string, newPassword: string) {
return apiClient.put<ApiResponse<void>>('/employees/me/password', { currentPassword, newPassword });
},
};
export interface PortfolioEntry {
dispatchId: number;
projectName: string;
roleTitle: string;
contributionRate: number;
individualTech: string;
performedTasks: string;
achievements: string | null;
startDate: string;
endDate: string | null;
internalRating: number | null;
reports: { targetDate: string; reportType: string; completedTasks: string }[];
resolvedTickets: { ticketId: number; title: string; category: string; resolutionNote: string | null }[];
}
export interface CreateDispatchRequest {
employeeId: number;
projectId: number;
roleTitle: string;
contributionRate: number;
individualTech: string;
performedTasks: string;
startDate?: string;
}
export default employeeService;
+52
View File
@@ -0,0 +1,52 @@
import apiClient from './apiClient';
import type { ApiResponse, Page, Equipment, EquipmentAssignment, EquipmentMaintenance } from '@/types';
const equipmentService = {
getAll(params?: { type?: string; status?: string; manufacturer?: string; page?: number; size?: number }) {
return apiClient.get<ApiResponse<Page<Equipment>>>('/equipment', { params });
},
getOne(equipId: number) {
return apiClient.get<ApiResponse<Equipment>>(`/equipment/${equipId}`);
},
create(data: Partial<Equipment>) {
return apiClient.post<ApiResponse<Equipment>>('/equipment', data);
},
update(equipId: number, data: Partial<Equipment>) {
return apiClient.put<ApiResponse<Equipment>>(`/equipment/${equipId}`, data);
},
delete(equipId: number) {
return apiClient.delete<ApiResponse<void>>(`/equipment/${equipId}`);
},
assign(equipId: number, assigneeId: number, conditionNote: string) {
return apiClient.post<ApiResponse<EquipmentAssignment>>(`/equipment/${equipId}/assign`, {
assigneeId, conditionNote,
});
},
return_(equipId: number, data: { conditionNote?: string }) {
return apiClient.put<ApiResponse<EquipmentAssignment>>(`/equipment/${equipId}/return`, data);
},
getAssignments(equipId: number) {
return apiClient.get<ApiResponse<EquipmentAssignment[]>>(`/equipment/${equipId}/assignments`);
},
getMaintenances(equipId: number) {
return apiClient.get<ApiResponse<EquipmentMaintenance[]>>(`/equipment/${equipId}/maintenances`);
},
addMaintenance(equipId: number, data: Partial<EquipmentMaintenance>) {
return apiClient.post<ApiResponse<EquipmentMaintenance>>(`/equipment/${equipId}/maintenance`, data);
},
getSummary() {
return apiClient.get<ApiResponse<Record<string, number>>>('/equipment/summary');
},
};
export default equipmentService;
+7
View File
@@ -0,0 +1,7 @@
export { default as apiClient } from './apiClient';
export { default as ticketService } from './ticketService';
export { default as equipmentService } from './equipmentService';
export { default as reportService } from './reportService';
export { default as employeeService } from './employeeService';
export { default as trainingService } from './trainingService';
export { default as notificationService } from './notificationService';
+20
View File
@@ -0,0 +1,20 @@
import apiClient from './apiClient';
import type { ApiResponse, Notification } from '@/types';
const notificationService = {
getUnreadCount() {
return apiClient.get<ApiResponse<{ count: number }>>('/notifications/unread');
},
getAll(page = 0, size = 20) {
return apiClient.get<ApiResponse<Notification[]>>('/notifications', {
params: { page, size },
});
},
markAsRead(notificationId: number) {
return apiClient.put<ApiResponse<void>>(`/notifications/${notificationId}/read`);
},
};
export default notificationService;
+32
View File
@@ -0,0 +1,32 @@
import apiClient from './apiClient';
import type { ApiResponse, Project } from '@/types';
const projectService = {
getMyProjects(): Promise<ApiResponse<Project[]>> {
return apiClient.get('/projects/my');
},
getProject(projectId: number): Promise<ApiResponse<Project>> {
return apiClient.get(`/projects/${projectId}`);
},
getAll(status?: string): Promise<ApiResponse<Project[]>> {
return apiClient.get('/projects', {
params: status ? { status } : undefined,
});
},
create(data: Record<string, unknown>): Promise<ApiResponse<Project>> {
return apiClient.post('/projects', data);
},
update(projectId: number, data: Record<string, unknown>): Promise<ApiResponse<Project>> {
return apiClient.put(`/projects/${projectId}`, data);
},
deleteProject(projectId: number): Promise<ApiResponse<void>> {
return apiClient.delete(`/projects/${projectId}`);
},
};
export default projectService;
+44
View File
@@ -0,0 +1,44 @@
import apiClient from './apiClient';
import type { ApiResponse, Page, ReportSummary, WorkReport } from '@/types';
const reportService = {
getAll(params?: { reportType?: string; status?: string; userId?: number; page?: number; size?: number }) {
return apiClient.get<ApiResponse<Page<WorkReport>>>('/reports', { params });
},
getOne(reportId: number) {
return apiClient.get<ApiResponse<WorkReport>>(`/reports/${reportId}`);
},
create(data: Partial<WorkReport>) {
return apiClient.post<ApiResponse<WorkReport>>('/reports', data);
},
updateStatus(reportId: number, status: string) {
return apiClient.put<ApiResponse<WorkReport>>(`/reports/${reportId}/status`, null, {
params: { status },
});
},
review(reportId: number, status: 'APPROVED' | 'REJECTED', reviewComment: string) {
return apiClient.put<ApiResponse<WorkReport>>(`/reports/${reportId}/review`, {
status, reviewComment,
});
},
delete(reportId: number) {
return apiClient.delete<ApiResponse<void>>(`/reports/${reportId}`);
},
getSummary() {
return apiClient.get<ApiResponse<ReportSummary>>('/reports/summary');
},
getSrAutoComplete(targetDate: string) {
return apiClient.get<ApiResponse<string[]>>('/reports/sr-auto-complete', {
params: { targetDate },
});
},
};
export default reportService;
+56
View File
@@ -0,0 +1,56 @@
import apiClient from './apiClient';
import type { ApiResponse, Page, IssueTicket, TicketComment } from '@/types';
export interface TicketFilterParams {
category?: string;
priority?: string;
status?: string;
searchKeyword?: string;
page?: number;
size?: number;
}
const ticketService = {
getTickets(params?: TicketFilterParams) {
return apiClient.get<ApiResponse<Page<IssueTicket>>>('/tickets', { params });
},
getTicket(ticketId: number) {
return apiClient.get<ApiResponse<IssueTicket>>(`/tickets/${ticketId}`);
},
createTicket(data: Partial<IssueTicket>) {
return apiClient.post<ApiResponse<IssueTicket>>('/tickets', data);
},
updateStatus(ticketId: number, status: string, assigneeId?: number) {
return apiClient.put<ApiResponse<IssueTicket>>(`/tickets/${ticketId}/status`, null, {
params: { status, assigneeId },
});
},
resolveTicket(ticketId: number, resolutionNote: string) {
return apiClient.put<ApiResponse<IssueTicket>>(`/tickets/${ticketId}/resolve`, { resolutionNote });
},
deleteTicket(ticketId: number) {
return apiClient.delete<ApiResponse<void>>(`/tickets/${ticketId}`);
},
getComments(ticketId: number) {
return apiClient.get<ApiResponse<TicketComment[]>>(`/tickets/${ticketId}/comments`);
},
addComment(ticketId: number, commentText: string, attachedFileUrl?: string) {
return apiClient.post<ApiResponse<TicketComment>>(`/tickets/${ticketId}/comments`, {
commentText,
attachedFileUrl,
});
},
deleteComment(ticketId: number, commentId: number) {
return apiClient.delete<ApiResponse<void>>(`/tickets/${ticketId}/comments/${commentId}`);
},
};
export default ticketService;
+29
View File
@@ -0,0 +1,29 @@
import apiClient from './apiClient';
import type { ApiResponse, TrainingProgress } from '@/types';
const trainingService = {
getMyProgress() {
return apiClient.get<ApiResponse<TrainingProgress[]>>('/training/me');
},
getByUser(userId: number) {
return apiClient.get<ApiResponse<TrainingProgress[]>>('/training', {
params: { userId },
});
},
update(progressId: number, data: { progressRate?: number; status?: string }) {
return apiClient.put<ApiResponse<TrainingProgress>>(`/training/${progressId}`, data);
},
getSummary() {
return apiClient.get<ApiResponse<Array<{
courseName: string;
totalEnrolled: number;
completedCount: number;
avgProgress: number;
}>>>('/training/summary');
},
};
export default trainingService;
+12
View File
@@ -0,0 +1,12 @@
import apiClient from './apiClient';
import type { ApiResponse, WakeRequest, WakeResponse } from '@/types';
const wakeService = {
requestWake: (data: WakeRequest) =>
apiClient.post<ApiResponse<WakeResponse>>('/wake', data),
checkStatus: () =>
apiClient.get<ApiResponse<{ parked: boolean }>>('/health'),
};
export default wakeService;
+87
View File
@@ -0,0 +1,87 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Employee, LoginRequest } from '@/types';
import employeeService from '@/services/employeeService';
import { setCookie, removeCookie } from '@/lib/cookie';
interface AuthState {
user: Employee | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
login: (credentials: LoginRequest) => Promise<Employee>;
logout: () => void;
fetchMe: () => Promise<void>;
setUser: (user: Employee) => void;
tryRefresh: () => Promise<boolean>;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
login: async (credentials) => {
const res = await employeeService.login(credentials);
const { accessToken, refreshToken, employee } = res.data;
if (typeof window !== 'undefined') {
localStorage.setItem('accessToken', accessToken);
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
setCookie('accessToken', accessToken);
}
set({ user: employee, accessToken, refreshToken, isAuthenticated: true });
return employee;
},
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
removeCookie('accessToken');
}
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false });
},
fetchMe: async () => {
try {
const res = await employeeService.getMe();
set({ user: res.data, isAuthenticated: true });
} catch {
get().logout();
}
},
setUser: (user) => set({ user, isAuthenticated: true }),
tryRefresh: async () => {
try {
const refreshToken = get().refreshToken;
if (!refreshToken) return false;
const res = await employeeService.refresh(refreshToken);
const { accessToken, refreshToken: newRefresh } = res.data;
if (typeof window !== 'undefined') {
localStorage.setItem('accessToken', accessToken);
if (newRefresh) localStorage.setItem('refreshToken', newRefresh);
setCookie('accessToken', accessToken);
}
set({ accessToken, refreshToken: newRefresh || refreshToken });
return true;
} catch {
return false;
}
},
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
},
),
);
+3
View File
@@ -0,0 +1,3 @@
export { useAuthStore } from './authStore';
export { useNotificationStore } from './notificationStore';
export { useParkingStore } from './parkingStore';
+59
View File
@@ -0,0 +1,59 @@
import { create } from 'zustand';
import notificationService from '@/services/notificationService';
import type { Notification } from '@/types';
interface NotificationState {
unreadCount: number;
notifications: Notification[];
polling: boolean;
fetchUnreadCount: () => Promise<void>;
fetchNotifications: () => Promise<void>;
markAsRead: (id: number) => Promise<void>;
startPolling: () => void;
stopPolling: () => void;
}
let pollingTimer: ReturnType<typeof setInterval> | null = null;
export const useNotificationStore = create<NotificationState>()((set, get) => ({
unreadCount: 0,
notifications: [],
polling: false,
fetchUnreadCount: async () => {
try {
const res = await notificationService.getUnreadCount();
set({ unreadCount: res.data.count });
} catch { /* ignore polling errors */ }
},
fetchNotifications: async () => {
try {
const res = await notificationService.getAll();
set({ notifications: res.data });
} catch { /* ignore */ }
},
markAsRead: async (id) => {
await notificationService.markAsRead(id);
get().fetchUnreadCount();
get().fetchNotifications();
},
startPolling: () => {
if (pollingTimer) return;
get().fetchUnreadCount();
pollingTimer = setInterval(() => {
get().fetchUnreadCount();
}, 30000);
set({ polling: true });
},
stopPolling: () => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
set({ polling: false });
},
}));
+77
View File
@@ -0,0 +1,77 @@
import { create } from 'zustand';
import wakeService from '@/services/wakeService';
interface ParkingState {
parked: boolean;
checking: boolean;
wakeLoading: boolean;
wakeSent: boolean;
wakeMessage: string;
checkParking: () => Promise<void>;
requestWake: (reason: string) => Promise<void>;
dismissWake: () => void;
}
const isParkingHours = (): boolean => {
const now = new Date();
const day = now.getDay();
const hour = now.getHours();
// Weekend (Sat=6, Sun=0) → always parked
if (day === 0 || day === 6) return true;
// Weekday 20:00 ~ 08:00 → parked
if (hour < 8 || hour >= 20) return true;
return false;
};
export const useParkingStore = create<ParkingState>()((set, get) => ({
parked: false,
checking: true,
wakeLoading: false,
wakeSent: false,
wakeMessage: '',
checkParking: async () => {
set({ checking: true });
try {
const res = await wakeService.checkStatus();
if (res.data.parked) {
set({ parked: true, checking: false });
return;
}
} catch {
// Network error → check if within parking hours
if (isParkingHours()) {
set({ parked: true, checking: false });
return;
}
}
set({ parked: false, checking: false });
},
requestWake: async (reason: string) => {
set({ wakeLoading: true, wakeSent: false, wakeMessage: '' });
try {
const name = typeof window !== 'undefined'
? localStorage.getItem('userName') || 'Unknown'
: 'Unknown';
const res = await wakeService.requestWake({
requestedBy: name,
reason,
requestedAt: new Date().toISOString(),
});
set({
wakeLoading: false,
wakeSent: true,
wakeMessage: res.data.message,
});
} catch {
set({
wakeLoading: false,
wakeSent: true,
wakeMessage: '요청이 전송되었습니다. 관리자가 확인 후 서버를 기동합니다.',
});
}
},
dismissWake: () => set({ wakeSent: false, wakeMessage: '' }),
}));
+318
View File
@@ -0,0 +1,318 @@
// ============================================
// 공통 응답 타입
// ============================================
export interface ApiResponse<T> {
success: boolean;
message: string;
data: T;
errorCode?: string;
}
export interface Page<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
// ============================================
// 공용 코드/상태 유니온
// ============================================
export type RoleCode = 'ADMIN' | 'MANAGER' | 'EMPLOYEE';
export type EmpStatus = 'ACTIVE' | 'ON_LEAVE' | 'RESIGNED';
export type DispatchStatus = 'WAITING' | 'DISPATCHED';
export type TicketStatus = 'OPEN' | 'ASSIGNED' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
export type TicketCategory = 'BUG' | 'FEATURE' | 'SUPPORT' | 'QA';
export type TicketPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'CRITICAL';
export type ReportType = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'RETURN';
export type ReportStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'REJECTED';
export type EquipmentType = 'LAPTOP' | 'MONITOR' | 'TEST_DEVICE' | 'ACCESSORY';
export type EquipmentStatus = 'AVAILABLE' | 'IN_USE' | 'REPAIRING' | 'RETIRED' | 'LOST';
export type MaintenanceType = 'REPAIR' | 'UPGRADE' | 'INSPECTION' | 'DISPOSAL';
export type ProjectType = 'SI' | 'SM' | 'MIGRATION';
export type ContractType = 'TURN_KEY' | 'MM';
export type ProjectStatus = 'PREPARING' | 'ONGOING' | 'COMPLETED' | 'HOLD';
export type WorkLocationType = 'ON_SITE' | 'REMOTE' | 'HYBRID';
export type TrainingStatus = 'NOT_STARTED' | 'ONGOING' | 'COMPLETED';
// ============================================
// Employee (직원)
// ============================================
export interface Employee {
empId: number;
loginId: string;
name: string;
email: string;
roleCode: RoleCode;
empStatus: EmpStatus;
dispatchStatus: DispatchStatus;
joinedDate: string;
leftDate: string | null;
}
export interface EmployeeSearchResult {
empId: number;
name: string;
roleCode: RoleCode;
dispatchStatus: DispatchStatus;
email: string;
}
// ============================================
// Project (프로젝트)
// ============================================
export interface Project {
projectId: number;
projectType: ProjectType;
projectName: string;
contractCompany: string;
endUserCompany: string;
contractType: ContractType;
status: ProjectStatus;
startDate: string;
endDate: string;
workLocationType: WorkLocationType;
workAddress: string;
techEnvironment: string;
mainTaskDesc: string;
internalManagerId: number | null;
internalManager?: { empId: number; name: string; email: string; } | null;
}
// ============================================
// DispatchHistory (파견 이력 / 포트폴리오)
// ============================================
export interface DispatchHistory {
dispatchId: number;
empId: number;
projectId: number;
projectName?: string;
startDate: string;
endDate: string | null;
roleTitle: string;
contributionRate: number;
individualTech: string;
performedTasks: string;
achievements: string | null;
clientFeedback: string | null;
internalRating: number | null;
}
// ============================================
// IssueTicket (SR 티켓)
// ============================================
export interface IssueTicket {
ticketId: number;
projectId: number;
projectName?: string;
requesterId: number;
requesterName?: string;
assigneeId: number | null;
assigneeName?: string | null;
category: TicketCategory;
priority: TicketPriority;
title: string;
description: string;
status: TicketStatus;
dueDate: string | null;
resolvedAt: string | null;
resolutionNote: string | null;
slaWarnFlag: 'Y' | 'N';
isImminent?: boolean;
createdAt: string;
updatedAt: string;
}
export interface TicketComment {
commentId: number;
ticketId: number;
userId: number;
userName?: string;
commentText: string;
attachedFileUrl: string | null;
createdAt: string;
}
// ============================================
// WorkReport (업무 보고서)
// ============================================
export interface WorkReport {
reportId: number;
dispatchId: number;
userId: number;
userName?: string;
reportType: ReportType;
targetDate: string;
completedTasks: string;
plannedTasks: string;
issuesAndRisks: string | null;
supportRequested: string | null;
status: ReportStatus;
workHours?: number | null;
blockerFlag?: string;
artifacts?: string;
reviewerId: number | null;
reviewerName?: string | null;
reviewComment: string | null;
createdAt: string;
updatedAt: string;
}
// ============================================
// Equipment (IT 자산)
// ============================================
export interface EquipmentImage {
url: string;
name: string;
uploadedAt: string;
}
export interface Equipment {
equipmentId: number;
equipmentType: EquipmentType;
manufacturer: string;
modelName: string;
serialNumber: string;
assetCode: string;
specifications: Record<string, string>;
status: EquipmentStatus;
purchaseDate: string;
warrantyExpDate: string;
version: number;
images?: EquipmentImage[] | string;
}
export interface EquipmentAssignment {
assignmentId: number;
equipmentId: number;
userId: number;
userName?: string;
assignedDate: string;
returnedDate: string | null;
condOnAssign: string;
condOnReturn: string | null;
}
export interface EquipmentMaintenance {
maintenanceId: number;
equipmentId: number;
reportedBy: number;
reporterName?: string;
maintenanceType: MaintenanceType;
issueDescription: string;
actionTaken: string | null;
cost: number;
reportedDate: string;
resolvedDate: string | null;
}
// ============================================
// Training (교육 진도)
// ============================================
export interface TrainingProgress {
progressId: number;
userId: number;
courseName: string;
progressRate: number;
status: TrainingStatus;
updatedAt: string;
}
export interface TrainingSummary {
courseName: string;
totalEnrolled: number;
completedCount: number;
avgProgress: number;
}
// ============================================
// Notification (알림)
// ============================================
export interface Notification {
notificationId: number;
type: string;
title: string;
message: string;
isRead: boolean;
linkUrl?: string;
createdAt: string;
}
// ============================================
// CalendarEvent (통합 일정)
// ============================================
export interface CalendarEvent {
eventType: 'TICKET_DUE' | 'REPORT_DUE' | 'DISPATCH_START' | 'DISPATCH_END';
sourceId: number;
title: string;
description?: string;
eventDate: string;
color: string;
linkUrl?: string;
}
// ============================================
// ReportSummary (보고서 결재 요약)
// ============================================
export interface ReportSummary {
submitted: number;
approved: number;
rejected: number;
draft: number;
}
// ============================================
// Parking (서버 파킹)
// ============================================
export interface ParkingStatus {
parked: boolean;
message: string;
resumeTime: string;
parkingSchedule: string;
}
export interface WakeRequest {
requestedBy: string;
reason: string;
requestedAt: string;
}
export interface WakeResponse {
success: boolean;
message: string;
estimatedWakeTime: string | null;
}
// ============================================
// Auth
// ============================================
export interface LoginRequest {
loginId: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
employee: Employee;
}
// ============================================
// Common Code
// ============================================
export interface CommonCode {
codeId: number;
groupCode: string;
codeValue: string;
codeName: string;
optionVal1: string | null;
optionVal2: string | null;
sortOrder: number;
isActive: 'Y' | 'N';
}