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>
);
}