init: SR System — Helpdesk & ITAM Portal
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CalendarClient';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import MainShell from "@/components/layout/MainShell";
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return <MainShell>{children}</MainShell>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import TrainingDashboard from '@/components/training/TrainingDashboard';
|
||||
|
||||
export default function TrainingPage() {
|
||||
return <TrainingDashboard />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user