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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Search } from 'lucide-react';
|
||||
import dispatchHistoryService from '@/services/dispatchHistoryService';
|
||||
import employeeService from '@/services/employeeService';
|
||||
import projectService from '@/services/projectService';
|
||||
import type { EmployeeSearchResult, Project } from '@/types';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
editData?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export default function DispatchFormModal({ isOpen, onClose, onSaved, editData }: Props) {
|
||||
const [empSearch, setEmpSearch] = useState('');
|
||||
const [empResults, setEmpResults] = useState<EmployeeSearchResult[]>([]);
|
||||
const [selectedEmp, setSelectedEmp] = useState<EmployeeSearchResult | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [roleTitle, setRoleTitle] = useState('');
|
||||
const [contributionRate, setContributionRate] = useState(50);
|
||||
const [individualTech, setIndividualTech] = useState('');
|
||||
const [performedTasks, setPerformedTasks] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showEmpDropdown, setShowEmpDropdown] = useState(false);
|
||||
const debounceRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
projectService.getAll().then((res) => {
|
||||
setProjects(res.data || []);
|
||||
}).catch(() => {});
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editData) return;
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
setRoleTitle((editData.roleTitle as string) || '');
|
||||
setContributionRate((editData.contributionRate as number) || 50);
|
||||
setIndividualTech((editData.individualTech as string) || '');
|
||||
setPerformedTasks((editData.performedTasks as string) || '');
|
||||
setStartDate((editData.startDate as string) || '');
|
||||
setEndDate((editData.endDate as string) || '');
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [editData]);
|
||||
|
||||
const handleEmpSearch = (val: string) => {
|
||||
setEmpSearch(val);
|
||||
setShowEmpDropdown(true);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
if (val.trim().length >= 1) {
|
||||
employeeService.search(val.trim()).then((res) => {
|
||||
setEmpResults(res.data || []);
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
setEmpResults([]);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedEmp || !selectedProject) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
employeeId: selectedEmp.empId,
|
||||
projectId: selectedProject.projectId,
|
||||
roleTitle,
|
||||
contributionRate,
|
||||
individualTech,
|
||||
performedTasks,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
};
|
||||
await dispatchHistoryService.create(payload);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
alert('Failed to save dispatch.');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 className="font-outfit font-semibold text-lg text-text-main">New Dispatch</h3>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="relative">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Employee</label>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={empSearch}
|
||||
onChange={(e) => handleEmpSearch(e.target.value)}
|
||||
onFocus={() => setShowEmpDropdown(true)}
|
||||
placeholder="Search employee name..."
|
||||
className="w-full pl-9 pr-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
{showEmpDropdown && empResults.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-border rounded-xl shadow-lg max-h-48 overflow-y-auto">
|
||||
{empResults.map((e) => (
|
||||
<button
|
||||
key={e.empId}
|
||||
type="button"
|
||||
onClick={() => { setSelectedEmp(e); setEmpSearch(e.name); setShowEmpDropdown(false); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{e.name} <span className="text-text-muted text-xs">({e.email})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedEmp && !showEmpDropdown && (
|
||||
<p className="text-xs text-primary mt-1">Selected: {selectedEmp.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Project</label>
|
||||
<select
|
||||
value={selectedProject?.projectId || ''}
|
||||
onChange={(e) => {
|
||||
const p = projects.find((p) => p.projectId === Number(e.target.value));
|
||||
setSelectedProject(p || null);
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.projectId} value={p.projectId}>{p.projectName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Role Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roleTitle}
|
||||
onChange={(e) => setRoleTitle(e.target.value)}
|
||||
placeholder="e.g. Backend Developer"
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Contribution Rate: {contributionRate}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={contributionRate}
|
||||
onChange={(e) => setContributionRate(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Tech Stack</label>
|
||||
<textarea
|
||||
value={individualTech}
|
||||
onChange={(e) => setIndividualTech(e.target.value)}
|
||||
placeholder="e.g. Java, Spring Boot, React..."
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Performed Tasks</label>
|
||||
<textarea
|
||||
value={performedTasks}
|
||||
onChange={(e) => setPerformedTasks(e.target.value)}
|
||||
placeholder="Key tasks and responsibilities..."
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100 transition-colors">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !selectedEmp || !selectedProject || !roleTitle.trim()}
|
||||
className="px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Create Dispatch'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
User,
|
||||
Shield,
|
||||
MessageSquare,
|
||||
Lock,
|
||||
CheckCircle,
|
||||
FileCode,
|
||||
Send,
|
||||
Paperclip
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* CollaborationStream 컴포넌트
|
||||
* 역할별 색상 코딩이 적용된 이슈 타임라인을 표시합니다.
|
||||
*/
|
||||
export default function CollaborationStream() {
|
||||
const events = [
|
||||
{
|
||||
type: 'report',
|
||||
role: 'field',
|
||||
user: '김현장 (파견원)',
|
||||
time: '14:20',
|
||||
content: '결제 모듈 처리 속도가 현저히 느려지면서 서버가 다운되었습니다. 현재 CPU와 메모리가 임계치에 도달한 상태입니다.',
|
||||
badge: 'Field Observation'
|
||||
},
|
||||
{
|
||||
type: 'event',
|
||||
user: 'System',
|
||||
time: '14:22',
|
||||
content: '본사 전문가 [이전문 아키텍트]가 배정되었습니다.',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
type: 'internal',
|
||||
role: 'hq',
|
||||
user: '이전문 (아키텍트)',
|
||||
time: '14:35',
|
||||
content: '현장 로그를 보니 GC(Garbage Collection)가 빈번하게 발생하고 있습니다. 힙 메모리 설정값이 실제 물리 메모리 대비 낮게 설정된 것 같습니다.',
|
||||
badge: 'Internal Note',
|
||||
isInternal: true
|
||||
},
|
||||
{
|
||||
type: 'official',
|
||||
role: 'hq',
|
||||
user: '이전문 (아키텍트)',
|
||||
time: '14:45',
|
||||
content: '김현장님, 현재 메모리 고갈로 인한 행(Hang) 현상으로 보입니다. 우선 JVM 옵션 중 -Xmx 값을 4G에서 8G로 상향 조정해 주시기 바랍니다. 관련 명령어를 첨부합니다.',
|
||||
code: 'export JAVA_OPTS="-Xms8g -Xmx8g -XX:+UseG1GC"',
|
||||
badge: 'Official Solution'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-slate-900/40 border border-slate-800 rounded-2xl shadow-xl overflow-hidden">
|
||||
|
||||
{/* Stream Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-900/80">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MessageSquare className="w-4 h-4 text-indigo-400" />
|
||||
<h3 className="text-xs font-bold text-slate-300 uppercase tracking-widest">Activity Stream</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1.5 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="w-2 h-2 rounded-full bg-indigo-500"></div>
|
||||
<span>HQ</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
|
||||
<span>Field</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-500"></div>
|
||||
<span>Internal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stream Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
|
||||
{events.map((ev, i) => (
|
||||
<div key={i} className={`relative pl-8 ${ev.isInternal ? 'opacity-70' : ''}`}>
|
||||
{/* Timeline Line */}
|
||||
{i !== events.length - 1 && (
|
||||
<div className="absolute left-[15px] top-8 bottom-[-32px] w-[2px] bg-slate-800"></div>
|
||||
)}
|
||||
|
||||
{/* Timeline Node */}
|
||||
<div className={`absolute left-0 top-0 w-8 h-8 rounded-full flex items-center justify-center z-10 border-2 shadow-lg ${
|
||||
ev.isEvent ? 'bg-slate-800 border-slate-700' :
|
||||
ev.role === 'hq' ? (ev.isInternal ? 'bg-slate-800 border-slate-700' : 'bg-indigo-600 border-indigo-400') :
|
||||
'bg-emerald-600 border-emerald-400'
|
||||
}`}>
|
||||
{ev.isEvent ? <CheckCircle className="w-4 h-4 text-slate-400" /> :
|
||||
ev.isInternal ? <Lock className="w-4 h-4 text-slate-400" /> :
|
||||
<User className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className={`rounded-2xl p-5 border shadow-sm ${
|
||||
ev.isEvent ? 'bg-transparent border-transparent' :
|
||||
ev.isInternal ? 'bg-slate-800/40 border-slate-700 text-slate-400' :
|
||||
ev.role === 'hq' ? 'bg-indigo-900/20 border-indigo-500/20 text-slate-200' :
|
||||
'bg-emerald-900/20 border-emerald-500/20 text-slate-200'
|
||||
}`}>
|
||||
{ev.isEvent ? (
|
||||
<div className="text-sm font-medium text-slate-500 flex items-center">
|
||||
<span className="mr-2">{ev.content}</span>
|
||||
<span className="text-[10px] font-mono">{ev.time}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-bold text-white">{ev.user}</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wider ${
|
||||
ev.isInternal ? 'bg-slate-700 text-slate-400' :
|
||||
ev.role === 'hq' ? 'bg-indigo-500/20 text-indigo-400' :
|
||||
'bg-emerald-500/20 text-emerald-400'
|
||||
}`}>
|
||||
{ev.badge}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-slate-500">{ev.time}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed mb-4">{ev.content}</p>
|
||||
|
||||
{ev.code && (
|
||||
<div className="bg-slate-950 rounded-xl p-4 border border-slate-800 font-mono text-xs text-indigo-300 relative group">
|
||||
<div className="absolute top-2 right-2 p-1.5 hover:bg-slate-800 rounded text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<FileCode className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<code>{ev.code}</code>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stream Input */}
|
||||
<div className="p-4 bg-slate-900/80 border-t border-slate-800 backdrop-blur-md">
|
||||
<div className="max-w-4xl mx-auto flex items-end space-x-4">
|
||||
<div className="flex-1 bg-slate-950 border border-slate-800 rounded-2xl p-3 focus-within:border-indigo-500/50 transition-colors shadow-inner">
|
||||
<textarea
|
||||
placeholder="답변을 입력하거나 파일을 드래그하세요..."
|
||||
className="w-full bg-transparent text-sm text-slate-200 focus:outline-none resize-none min-h-[40px] max-h-[200px]"
|
||||
rows={2}
|
||||
></textarea>
|
||||
<div className="flex items-center justify-between pt-2 mt-2 border-t border-slate-800/50">
|
||||
<div className="flex items-center space-x-1">
|
||||
<button className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-500 transition-colors">
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-500 transition-colors">
|
||||
<FileCode className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="flex items-center space-x-2 cursor-pointer group">
|
||||
<div className="w-4 h-4 rounded border border-slate-700 bg-slate-800 flex items-center justify-center group-hover:border-indigo-500 transition-colors">
|
||||
<Lock className="w-2.5 h-2.5 text-slate-500" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase group-hover:text-slate-300">Internal Note</span>
|
||||
</label>
|
||||
<button className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-xl text-xs font-bold shadow-lg transition-all flex items-center">
|
||||
전송
|
||||
<Send className="w-3 h-3 ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
Database,
|
||||
Wifi,
|
||||
Terminal,
|
||||
Filter,
|
||||
Maximize2
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* DiagnosticCockpit 컴포넌트
|
||||
* 현장의 실시간 시스템 메트릭과 에러 로그를 시각화합니다.
|
||||
*/
|
||||
export default function DiagnosticCockpit() {
|
||||
const metrics = [
|
||||
{ label: 'CPU Usage', value: 84, color: 'text-orange-500', bg: 'bg-orange-500/10', bar: 'bg-orange-500', icon: Cpu },
|
||||
{ label: 'Memory', value: 92, color: 'text-red-500', bg: 'bg-red-500/10', bar: 'bg-red-500', icon: Database },
|
||||
{ label: 'Latency', value: 124, unit: 'ms', color: 'text-yellow-500', bg: 'bg-yellow-500/10', bar: 'bg-yellow-500', icon: Wifi },
|
||||
];
|
||||
|
||||
const logs = [
|
||||
{ time: '14:20:12', type: 'ERROR', message: 'java.lang.OutOfMemoryError: Java heap space' },
|
||||
{ time: '14:20:15', type: 'WARN', message: 'GC overhead limit exceeded' },
|
||||
{ time: '14:21:03', type: 'INFO', message: 'Connection pool established (32/100)' },
|
||||
{ time: '14:22:10', type: 'ERROR', message: 'Fail to process transaction [ID: TX-9002]' },
|
||||
{ time: '14:22:45', type: 'DEBUG', message: 'Snapshot thread started' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-6">
|
||||
|
||||
{/* Metrics Widgets */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{metrics.map((m, i) => (
|
||||
<div key={i} className="bg-slate-900 border border-slate-800 rounded-2xl p-4 shadow-sm group hover:border-slate-700 transition-colors">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`p-1.5 rounded-lg ${m.bg}`}>
|
||||
<m.icon className={`w-3.5 h-3.5 ${m.color}`} />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-tight">{m.label}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-mono font-bold ${m.color}`}>
|
||||
{m.value}{m.unit || '%'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${m.bar} transition-all duration-1000 ease-out`}
|
||||
style={{ width: `${Math.min(m.value, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Log Viewer */}
|
||||
<div className="flex-1 bg-slate-950 border border-slate-800 rounded-2xl flex flex-col overflow-hidden shadow-2xl">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Terminal className="w-4 h-4 text-indigo-400" />
|
||||
<h3 className="text-xs font-bold text-slate-300 uppercase tracking-wider">Field Diagnostic Logs</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button className="p-1.5 hover:bg-slate-800 rounded-md text-slate-500 transition-colors">
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-slate-800 rounded-md text-slate-500 transition-colors">
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 font-mono text-[11px] leading-relaxed space-y-2">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="flex items-start space-x-3 group hover:bg-white/5 p-1 rounded transition-colors cursor-pointer">
|
||||
<span className="text-slate-600 flex-shrink-0">[{log.time}]</span>
|
||||
<span className={`font-bold flex-shrink-0 w-12 ${
|
||||
log.type === 'ERROR' ? 'text-red-500' :
|
||||
log.type === 'WARN' ? 'text-yellow-500' :
|
||||
log.type === 'INFO' ? 'text-blue-400' : 'text-slate-500'
|
||||
}`}>
|
||||
{log.type}
|
||||
</span>
|
||||
<span className="text-slate-300 break-all">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="h-4"></div>
|
||||
<div className="flex items-center space-x-2 text-slate-600 italic">
|
||||
<span className="animate-pulse">_</span>
|
||||
<span>Tailing logs from site...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AlertCircle, Clock, CheckCircle2, User } from 'lucide-react';
|
||||
|
||||
export interface TicketCardData {
|
||||
ticketId: number;
|
||||
title: string;
|
||||
projectName?: string;
|
||||
requesterName?: string;
|
||||
assigneeName?: string | null;
|
||||
priority: string;
|
||||
category: string;
|
||||
status: string;
|
||||
dueDate?: string | null;
|
||||
isImminent?: boolean;
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, { color: string; bg: string; label: string }> = {
|
||||
OPEN: { color: 'text-status-open-text', bg: 'bg-status-open-bg', label: 'Open' },
|
||||
ASSIGNED: { color: 'text-blue-600', bg: 'bg-blue-50', label: 'Assigned' },
|
||||
IN_PROGRESS: { color: 'text-status-pending-text', bg: 'bg-status-pending-bg', label: 'In Progress' },
|
||||
RESOLVED: { color: 'text-status-resolved-text', bg: 'bg-status-resolved-bg', label: 'Resolved' },
|
||||
CLOSED: { color: 'text-status-closed-text', bg: 'bg-status-closed-bg', label: 'Closed' },
|
||||
};
|
||||
|
||||
const priorityConfig: Record<string, { color: string; bg: string; label: string }> = {
|
||||
CRITICAL: { color: 'text-status-critical-text', bg: 'bg-status-critical-bg', label: '긴급' },
|
||||
HIGH: { color: 'text-status-pending-text', bg: 'bg-status-pending-bg', label: '높음' },
|
||||
NORMAL: { color: 'text-status-open-text', bg: 'bg-status-open-bg', label: '보통' },
|
||||
LOW: { color: 'text-text-muted', bg: 'bg-gray-100', label: '낮음' },
|
||||
};
|
||||
|
||||
const categoryLabel: Record<string, string> = {
|
||||
BUG: '버그',
|
||||
FEATURE: '기능',
|
||||
SUPPORT: '기술지원',
|
||||
QA: 'QA',
|
||||
};
|
||||
|
||||
export default function TicketCard({ ticket, onClick }: { ticket: TicketCardData; onClick?: () => void }) {
|
||||
const pri = priorityConfig[ticket.priority] || priorityConfig.NORMAL;
|
||||
const stat = statusLabel[ticket.status];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl border border-border p-4 cursor-pointer hover:shadow-soft hover:border-primary/30 transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded ${pri.bg} ${pri.color}`}>
|
||||
{pri.label}
|
||||
</span>
|
||||
{stat && (
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded ${stat.bg} ${stat.color}`}>
|
||||
{stat.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-text-muted bg-gray-50 px-2 py-0.5 rounded">
|
||||
{categoryLabel[ticket.category] || ticket.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-xs font-semibold text-text-main leading-snug mb-3 line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{ticket.title}
|
||||
</h4>
|
||||
|
||||
{ticket.projectName && (
|
||||
<p className="text-[10px] text-text-muted mb-2 truncate">{ticket.projectName}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-text-muted pt-2 border-t border-border/50">
|
||||
<div className="flex items-center">
|
||||
<User className="w-3 h-3 mr-1" />
|
||||
<span className="truncate max-w-[80px]">{ticket.assigneeName || ticket.requesterName || '-'}</span>
|
||||
</div>
|
||||
{ticket.dueDate && (
|
||||
<div className={`flex items-center ${ticket.isImminent ? 'text-status-critical-text font-semibold' : ''}`}>
|
||||
{ticket.isImminent ? <AlertCircle className="w-3 h-3 mr-1" /> : <Clock className="w-3 h-3 mr-1" />}
|
||||
<span>{new Date(ticket.dueDate).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanColumn({ title, count, color, children }: { title: string; count: number; color: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 min-w-[240px] max-w-[320px] flex flex-col">
|
||||
<div className="flex items-center space-x-2 mb-4 px-1">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${color}`} />
|
||||
<h3 className="font-outfit font-semibold text-sm text-text-main">{title}</h3>
|
||||
<span className="text-xs text-text-muted bg-gray-100 px-2 py-0.5 rounded-full font-medium">{count}</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto min-h-[200px] bg-gray-50/30 rounded-2xl p-3">
|
||||
{count === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-text-muted">
|
||||
<CheckCircle2 className="w-8 h-8 mb-2 opacity-30" />
|
||||
<p className="text-xs">No tickets</p>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
ArrowRight,
|
||||
Lightbulb,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* SopRoadmap 컴포넌트
|
||||
* 전문가가 제시하는 단계별 해결 절차와 진행률을 표시합니다.
|
||||
*/
|
||||
export default function SopRoadmap() {
|
||||
const [steps, setSteps] = useState([
|
||||
{ id: 1, title: 'JVM Heap 설정 확인', status: 'completed', description: '현재 설정된 -Xmx, -Xms 값을 서버 로그에서 확인' },
|
||||
{ id: 2, title: 'JVM 옵션 상향 조정', status: 'in-progress', description: '기존 4G에서 8G로 변경 후 서비스 재기동' },
|
||||
{ id: 3, title: '재기동 후 GC 로그 모니터링', status: 'pending', description: 'Full GC 발생 주기 및 처리 시간 확인' },
|
||||
{ id: 4, title: '최종 안정성 검증', status: 'pending', description: '24시간 동안 메모리 누수 발생 여부 체크' },
|
||||
]);
|
||||
|
||||
const completedCount = steps.filter(s => s.status === 'completed').length;
|
||||
const progress = Math.round((completedCount / steps.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Progress Card */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ClipboardList className="w-4 h-4 text-indigo-400" />
|
||||
<h3 className="text-xs font-bold text-slate-300 uppercase tracking-widest">SOP Roadmap</h3>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-indigo-400 px-1.5 py-0.5 bg-indigo-500/10 rounded border border-indigo-500/20">
|
||||
{progress}% Done
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-slate-800 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-indigo-500 transition-all duration-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 text-right font-medium">{completedCount} of {steps.length} steps completed</p>
|
||||
</div>
|
||||
|
||||
{/* Step List */}
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, i) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`bg-slate-900/60 border rounded-2xl p-4 transition-all ${
|
||||
step.status === 'in-progress' ? 'border-indigo-500/50 bg-indigo-500/5 shadow-soft' :
|
||||
step.status === 'completed' ? 'border-slate-800 opacity-60' : 'border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="mt-0.5">
|
||||
{step.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||
) : step.status === 'in-progress' ? (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-indigo-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500 animate-ping"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-slate-700" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={`text-sm font-bold mb-1 ${
|
||||
step.status === 'completed' ? 'text-slate-400 line-through' :
|
||||
step.status === 'in-progress' ? 'text-white' : 'text-slate-500'
|
||||
}`}>
|
||||
{step.title}
|
||||
</h4>
|
||||
{step.status !== 'completed' && (
|
||||
<p className="text-xs text-slate-500 leading-relaxed mb-3">{step.description}</p>
|
||||
)}
|
||||
|
||||
{step.status === 'in-progress' && (
|
||||
<button className="flex items-center text-[10px] font-bold text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
마무리 후 다음 단계로
|
||||
<ArrowRight className="w-3 h-3 ml-1.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expert Tips */}
|
||||
<div className="bg-amber-900/10 border border-amber-500/20 rounded-2xl p-4">
|
||||
<div className="flex items-center space-x-2 mb-2 text-amber-500">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">Safety Reminder</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-200/60 leading-relaxed">
|
||||
JVM 설정을 변경할 때는 서비스 가용성에 영향을 줄 수 있으므로, 반드시 **운영 시간 이후(PM 10:00)**에 작업을 진행해 주십시오.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
Wrench,
|
||||
Camera,
|
||||
Upload,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AssetActionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
asset?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* IT 자산 통합 업무 처리 모달
|
||||
* 등록, 반입, 반출, 정비를 한 곳에서 처리합니다.
|
||||
*/
|
||||
export default function AssetActionModal({ isOpen, onClose, asset }: AssetActionModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'checkout' | 'checkin' | 'maintenance' | 'register'>('checkout');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'checkout', label: '반출 (배정)', icon: ArrowUpRight, color: 'text-orange-500 bg-orange-50' },
|
||||
{ id: 'checkin', label: '반입 (회수)', icon: ArrowDownLeft, color: 'text-blue-500 bg-blue-50' },
|
||||
{ id: 'maintenance', label: '정비 (수리)', icon: Wrench, color: 'text-purple-500 bg-purple-50' },
|
||||
{ id: 'register', label: '신규 등록', icon: Plus, color: 'text-emerald-500 bg-emerald-50' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-text-main/60 backdrop-blur-sm" onClick={onClose}></div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative bg-surface w-full max-w-2xl rounded-[32px] shadow-modal overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="px-8 py-6 border-b border-border flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-text-main">장비 업무 처리</h3>
|
||||
<p className="text-xs text-text-muted mt-1">자산의 상태 변경 및 이동 이력을 기록합니다.</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex px-4 py-2 bg-gray-50/50 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 flex items-center justify-center space-x-2 py-3 px-4 rounded-2xl text-xs font-bold transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'bg-surface text-text-main shadow-sm ring-1 ring-border/50'
|
||||
: 'text-text-muted hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className={`w-3.5 h-3.5 ${activeTab === tab.id ? tab.color.split(' ')[0] : ''}`} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="p-8 max-h-[60vh] overflow-y-auto">
|
||||
|
||||
{/* Selected Asset Info (Summary) */}
|
||||
{asset && (
|
||||
<div className="mb-8 p-4 bg-gray-50 rounded-2xl border border-border flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface border border-border flex items-center justify-center mr-3 shadow-card">
|
||||
<Camera className="w-5 h-5 text-text-muted" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase tracking-wider">{asset.id}</p>
|
||||
<p className="text-sm font-bold text-text-main">{asset.model}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase">현재 상태</p>
|
||||
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
|
||||
{asset.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Content Based on Tab */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{activeTab === 'checkout' && (
|
||||
<>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">대상 프로젝트</label>
|
||||
<select className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all">
|
||||
<option>삼성페이 고도화 (SI)</option>
|
||||
<option>카카오 유지보수</option>
|
||||
<option>본사 R&D 센터</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">사용자 (파견원) 검색</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="이름을 입력하여 검색..."
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
<div className="absolute z-10 mt-1 w-full bg-surface rounded-xl border border-border shadow-float max-h-40 overflow-y-auto hidden">
|
||||
<div className="p-3 hover:bg-gray-50 cursor-pointer border-b border-border">
|
||||
<p className="text-sm font-medium text-text-main">김코딩</p>
|
||||
<p className="text-[10px] text-text-muted">BACKEND · 삼성페이 SI</p>
|
||||
</div>
|
||||
<div className="p-3 hover:bg-gray-50 cursor-pointer border-b border-border">
|
||||
<p className="text-sm font-medium text-text-main">이현장</p>
|
||||
<p className="text-[10px] text-text-muted">DBA · LG CNS SM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">지급 상태 코멘트</label>
|
||||
<input type="text" placeholder="예: 미개봉 신품" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">반출 예정일</label>
|
||||
<input type="date" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'checkin' && (
|
||||
<>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">반입 사유</label>
|
||||
<select className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all">
|
||||
<option>프로젝트 종료</option>
|
||||
<option>장비 교체 (업그레이드)</option>
|
||||
<option>사용자 퇴사/인사이동</option>
|
||||
<option>기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">반납 상태 코멘트</label>
|
||||
<input type="text" placeholder="예: C상판 커피 침수 흔적 - 수리 필요" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'maintenance' && (
|
||||
<>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">증상 및 정비 사유</label>
|
||||
<textarea rows={3} placeholder="고장 증상을 상세히 적어주세요." className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all resize-none"></textarea>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">정비 업체</label>
|
||||
<input type="text" placeholder="업체명 입력" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">예상 완료일</label>
|
||||
<input type="date" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'register' && (
|
||||
<>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">모델명</label>
|
||||
<input type="text" placeholder='예: MacBook Pro 16"' className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">자산 유형</label>
|
||||
<select className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all">
|
||||
<option>노트북</option>
|
||||
<option>데스크탑</option>
|
||||
<option>서버</option>
|
||||
<option>모니터</option>
|
||||
<option>기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-text-muted ml-1">시리얼 번호</label>
|
||||
<input type="text" placeholder="S/N 입력" className="w-full px-4 py-3 bg-gray-50 border border-border rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Simple Photo Record Area (단순 기록용 사진 첨부) */}
|
||||
<div className="col-span-2 mt-4">
|
||||
<label className="text-xs font-bold text-text-muted ml-1 mb-1.5 block">상태 기록 사진 (단순 참고용)</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1 border-2 border-dashed border-border rounded-2xl p-6 flex flex-col items-center justify-center hover:bg-gray-50 hover:border-primary/30 transition-all cursor-pointer">
|
||||
<Upload className="w-6 h-6 text-gray-300 mb-2" />
|
||||
<p className="text-[11px] font-bold text-text-muted">사진을 드래그하거나 클릭하여 첨부</p>
|
||||
</div>
|
||||
<div className="w-20 h-20 rounded-2xl bg-gray-100 border border-border flex items-center justify-center text-gray-300 relative group overflow-hidden">
|
||||
<Camera className="w-6 h-6" />
|
||||
<div className="absolute inset-0 bg-text-main/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[10px] font-bold text-white">촬영하기</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-8 py-6 bg-gray-50 border-t border-border flex justify-end space-x-3">
|
||||
<button onClick={onClose} className="px-6 py-2.5 rounded-xl text-sm font-bold text-text-muted hover:bg-gray-200 transition-all">
|
||||
취소
|
||||
</button>
|
||||
<button className="bg-primary text-white px-8 py-2.5 rounded-xl text-sm font-bold shadow-float hover:bg-primary-hover transition-all flex items-center">
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
기록 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Laptop,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Server,
|
||||
User,
|
||||
Calendar,
|
||||
MoreVertical,
|
||||
ExternalLink,
|
||||
History,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
Wrench,
|
||||
ChevronRight,
|
||||
Image as ImageIcon,
|
||||
X,
|
||||
QrCode
|
||||
} from 'lucide-react';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import AssetLabel from './AssetLabel';
|
||||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
* 자산 인벤토리 리스트 컴포넌트
|
||||
* IT 자산의 상세 목록을 출력하며 개별 액션을 제공합니다.
|
||||
*/
|
||||
export default function AssetInventory({ equipment, onRefresh, currentPage, totalPages, onPageChange }: { equipment: import('@/types').Equipment[]; onRefresh: () => void; currentPage?: number; totalPages?: number; onPageChange?: (page: number) => void }) {
|
||||
const [labelAsset, setLabelAsset] = useState<import('@/types').Equipment | null>(null);
|
||||
const { codes: equipmentStatusCodes, loading: statusCodesLoading } = useCommonCodes('EQUIPMENT_STATUS');
|
||||
|
||||
const statusLabel = statusCodesLoading ? {} : Object.fromEntries(equipmentStatusCodes.map((c) => [c.codeValue, c.codeName]));
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'LAPTOP': return <Laptop className="w-4 h-4 text-indigo-500" />;
|
||||
case 'SERVER': return <Server className="w-4 h-4 text-blue-500" />;
|
||||
case 'MONITOR': return <Monitor className="w-4 h-4 text-emerald-500" />;
|
||||
default: return <Laptop className="w-4 h-4 text-slate-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
switch (status) {
|
||||
case 'IN_USE': return 'bg-green-50 text-green-600 border-green-100';
|
||||
case 'AVAILABLE': return 'bg-blue-50 text-blue-600 border-blue-100';
|
||||
case 'REPAIRING': return 'bg-orange-50 text-orange-600 border-orange-100';
|
||||
case 'RETIRED': return 'bg-gray-100 text-gray-500 border-gray-200';
|
||||
default: return 'bg-gray-50 text-gray-600 border-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDot = (status: string) => {
|
||||
switch (status) {
|
||||
case 'IN_USE': return 'bg-green-500';
|
||||
case 'AVAILABLE': return 'bg-blue-500';
|
||||
case 'REPAIRING': return 'bg-orange-500';
|
||||
default: return 'bg-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/50 border-b border-slate-100 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
<th className="px-4 sm:px-8 py-4 font-bold w-12">Photo</th>
|
||||
<th className="px-4 sm:px-8 py-4 font-bold">Asset ID & Type</th>
|
||||
<th className="px-4 sm:px-8 py-4 font-bold">Model & Serial</th>
|
||||
<th className="px-4 sm:px-8 py-4 font-bold">Project / User</th>
|
||||
<th className="px-4 sm:px-8 py-4 font-bold">Status</th>
|
||||
<th className="px-4 sm:px-8 py-4 font-bold text-right">Context Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{equipment.map((asset) => (
|
||||
<tr key={asset.equipmentId} className="hover:bg-slate-50/30 transition-colors group">
|
||||
<td className="px-4 sm:px-8 py-5">
|
||||
{(() => {
|
||||
let imgs: { url: string }[] = [];
|
||||
if (asset.images) {
|
||||
try { imgs = typeof asset.images === 'string' ? JSON.parse(asset.images) : asset.images; } catch {}
|
||||
}
|
||||
return imgs.length > 0 ? (
|
||||
<div className="w-10 h-10 rounded-xl overflow-hidden border border-slate-200">
|
||||
<img src={imgs[0].url} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-100 border border-slate-200 flex items-center justify-center">
|
||||
<ImageIcon className="w-4 h-4 text-slate-300" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 sm:px-8 py-5">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center shadow-sm mr-4 group-hover:scale-110 transition-transform duration-300 flex-shrink-0">
|
||||
{getTypeIcon(asset.equipmentType)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-slate-900 truncate">{asset.assetCode}</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-tighter">{asset.equipmentType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-8 py-5">
|
||||
<p className="text-sm font-bold text-slate-900 truncate max-w-[150px] sm:max-w-none">{asset.modelName}</p>
|
||||
<p className="text-[11px] font-mono text-slate-400 truncate">{asset.serialNumber}</p>
|
||||
</td>
|
||||
<td className="px-4 sm:px-8 py-5">
|
||||
<p className="text-xs font-bold text-slate-700 truncate">{asset.manufacturer}</p>
|
||||
<p className="text-[11px] font-bold text-slate-400 whitespace-nowrap">Purchased: {asset.purchaseDate}</p>
|
||||
</td>
|
||||
<td className="px-4 sm:px-8 py-5">
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-[10px] font-bold border ${getStatusStyle(asset.status)}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full mr-2 ${getStatusDot(asset.status)}`}></div>
|
||||
{statusLabel[asset.status as keyof typeof statusLabel] || asset.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-8 py-5 text-right">
|
||||
<div className="flex items-center justify-end space-x-1 opacity-0 group-hover:opacity-100 transition-all">
|
||||
{asset.status === 'AVAILABLE' ? (
|
||||
<Link href="/itam/checkout" className="p-2 bg-orange-500 border border-orange-600 rounded-lg hover:bg-orange-600 text-white transition-all shadow-lg shadow-orange-500/20" title="장비 배정">
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
) : asset.status === 'IN_USE' ? (
|
||||
<Link href="/itam/checkin" className="p-2 bg-blue-600 border border-blue-700 rounded-lg hover:bg-blue-700 text-white transition-all shadow-lg shadow-blue-500/20" title="장비 회수">
|
||||
<ArrowDownLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
) : null}
|
||||
<button onClick={() => setLabelAsset(asset)} className="p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-400 hover:text-primary transition-all" title="자산 라벨">
|
||||
<QrCode className="w-4 h-4" />
|
||||
</button>
|
||||
<Link href={`/itam/history/${asset.equipmentId}`} className="p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-400 hover:text-blue-500 transition-all" title="이력 보기">
|
||||
<History className="w-4 h-4" />
|
||||
</Link>
|
||||
<button onClick={() => alert(`Maintenance request for: ${asset.modelName}\nAsset: ${asset.assetCode}\n\nThis feature will be available soon.`)} className="p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-400 hover:text-orange-500 transition-all" title="정비 의뢰">
|
||||
<Wrench className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{equipment.map((asset) => (
|
||||
<div key={asset.equipmentId} className="bg-white rounded-2xl border border-slate-200 p-4 space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{(() => {
|
||||
let imgs: {url:string}[] = [];
|
||||
try { imgs = typeof asset.images === 'string' ? JSON.parse(asset.images) : (asset.images || []); } catch {}
|
||||
return imgs.length > 0 ? (
|
||||
<div className="w-10 h-10 rounded-xl overflow-hidden border"><img src={imgs[0].url} alt="" className="w-full h-full object-cover"/></div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-100 border flex items-center justify-center">{getTypeIcon(asset.equipmentType)}</div>
|
||||
);
|
||||
})()}
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-900">{asset.modelName}</p>
|
||||
<p className="text-[10px] text-slate-400 font-mono">{asset.serialNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold px-2.5 py-1 rounded-full border ${getStatusStyle(asset.status)}`}>
|
||||
{statusLabel[asset.status as keyof typeof statusLabel] || asset.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-slate-500">
|
||||
<div><span className="text-slate-700 font-medium">Code:</span> {asset.assetCode}</div>
|
||||
<div><span className="text-slate-700 font-medium">Type:</span> {asset.equipmentType}</div>
|
||||
<div><span className="text-slate-700 font-medium">Maker:</span> {asset.manufacturer}</div>
|
||||
<div><span className="text-slate-700 font-medium">Date:</span> {asset.purchaseDate}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
|
||||
<button onClick={() => setLabelAsset(asset)} className="flex-1 py-2 bg-slate-100 rounded-xl text-xs font-medium text-slate-600 flex items-center justify-center gap-1"><QrCode className="w-3.5 h-3.5" /> Label</button>
|
||||
{asset.status === 'AVAILABLE' ? (
|
||||
<Link href="/itam/checkout" className="flex-1 py-2 bg-orange-500 text-white rounded-xl text-xs font-medium text-center">Assign</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination - shows on both mobile and desktop */}
|
||||
<div className="px-4 sm:px-8 py-5 bg-slate-50/30 border-t border-slate-100 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-bold text-slate-400">Showing {equipment.length} assets</p>
|
||||
{totalPages !== undefined && totalPages > 1 && (
|
||||
<div className="flex space-x-1">
|
||||
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i).map((p) => (
|
||||
<button key={p} onClick={() => onPageChange?.(p)}
|
||||
className={`w-8 h-8 rounded-xl text-[10px] font-bold transition-all flex items-center justify-center ${
|
||||
(currentPage ?? 0) === p ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'text-slate-400 hover:bg-white hover:border-slate-200 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{p + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{labelAsset && (
|
||||
<AssetLabel
|
||||
assetCode={labelAsset.assetCode}
|
||||
modelName={labelAsset.modelName}
|
||||
serialNumber={labelAsset.serialNumber}
|
||||
manufacturer={labelAsset.manufacturer}
|
||||
purchaseDate={labelAsset.purchaseDate}
|
||||
onClose={() => setLabelAsset(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface Props {
|
||||
assetCode: string;
|
||||
modelName: string;
|
||||
serialNumber: string;
|
||||
manufacturer: string;
|
||||
purchaseDate: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function AssetLabel({ assetCode, modelName, serialNumber, manufacturer, purchaseDate, onClose }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
QRCode.toCanvas(canvasRef.current, assetCode, {
|
||||
width: 160,
|
||||
margin: 1,
|
||||
color: { dark: '#1e293b', light: '#ffffff' },
|
||||
});
|
||||
}
|
||||
}, [assetCode]);
|
||||
|
||||
const handlePrint = async () => {
|
||||
const w = window.open('', '_blank');
|
||||
if (!w) return;
|
||||
|
||||
// Generate QR as data URL
|
||||
const qrCanvas = document.createElement('canvas');
|
||||
qrCanvas.width = 160;
|
||||
qrCanvas.height = 160;
|
||||
await QRCode.toCanvas(qrCanvas, assetCode, { width: 160, margin: 1 });
|
||||
const qrDataUrl = qrCanvas.toDataURL();
|
||||
|
||||
w.document.write(`
|
||||
<html><head><style>
|
||||
body { margin: 0; padding: 20px; font-family: Inter, sans-serif; }
|
||||
.label { width: 280px; border: 1px solid #ccc; padding: 16px; border-radius: 8px; margin: 0 auto; }
|
||||
.label img { display: block; margin: 0 auto 12px; width: 160px; height: 160px; }
|
||||
.code { font-size: 18px; font-weight: 700; text-align: center; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
.info { font-size: 11px; color: #555; line-height: 1.6; }
|
||||
.info span { color: #1e293b; font-weight: 600; }
|
||||
.title { text-align: center; font-size: 10px; color: #94a3b8; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 2px; }
|
||||
</style></head><body>
|
||||
<div class="label">
|
||||
<div class="title">SR System</div>
|
||||
<img src="${qrDataUrl}" alt="QR" />
|
||||
<div class="code">${assetCode}</div>
|
||||
<div class="info">
|
||||
Model: <span>${modelName}</span><br/>
|
||||
S/N: <span>${serialNumber}</span><br/>
|
||||
Maker: <span>${manufacturer}</span><br/>
|
||||
Date: <span>${purchaseDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>setTimeout(() => window.print(), 200);</script>
|
||||
</body></html>
|
||||
`);
|
||||
w.document.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl p-6 max-w-xs mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="text-center mb-2">
|
||||
<p className="text-[10px] text-slate-400 uppercase tracking-widest font-semibold">SR System · Asset Label</p>
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="mx-auto mb-3" />
|
||||
<p className="text-lg font-bold text-center tracking-wider text-slate-900">{assetCode}</p>
|
||||
<div className="mt-3 text-xs text-slate-500 space-y-0.5">
|
||||
<p><span className="text-slate-700 font-medium">Model:</span> {modelName}</p>
|
||||
<p><span className="text-slate-700 font-medium">S/N:</span> {serialNumber}</p>
|
||||
<p><span className="text-slate-700 font-medium">Maker:</span> {manufacturer}</p>
|
||||
<p><span className="text-slate-700 font-medium">Date:</span> {purchaseDate}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-5">
|
||||
<button onClick={onClose} className="flex-1 px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:bg-slate-100 transition-all">Close</button>
|
||||
<button onClick={handlePrint} className="flex-1 px-4 py-2 rounded-xl text-sm font-medium bg-slate-900 text-white hover:bg-slate-800 transition-all">Print Label</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Box, CheckCircle2, Wrench, History, TrendingUp } from 'lucide-react';
|
||||
|
||||
export default function AssetStatusOverview({
|
||||
summary,
|
||||
filter,
|
||||
onFilterChange,
|
||||
}: {
|
||||
summary: Record<string, number>;
|
||||
filter: string;
|
||||
onFilterChange: (f: string) => void;
|
||||
}) {
|
||||
const stats = [
|
||||
{ label: 'Total Assets', value: summary.ALL || 0, icon: Box, color: 'text-primary bg-indigo-50', key: 'ALL' },
|
||||
{ label: 'In Use', value: summary.IN_USE || 0, icon: CheckCircle2, color: 'text-green-600 bg-green-50', key: 'IN_USE' },
|
||||
{ label: 'Available', value: summary.AVAILABLE || 0, icon: History, color: 'text-blue-600 bg-blue-50', key: 'AVAILABLE' },
|
||||
{ label: 'Under Repair', value: summary.REPAIRING || 0, icon: Wrench, color: 'text-orange-600 bg-orange-50', key: 'REPAIRING' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat) => (
|
||||
<button
|
||||
key={stat.key}
|
||||
onClick={() => onFilterChange(stat.key === 'ALL' ? 'ALL' : stat.key)}
|
||||
className={`bg-surface p-6 rounded-3xl border transition-all text-left relative overflow-hidden group hover:shadow-soft ${
|
||||
filter === stat.key ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${stat.color}`}>
|
||||
<stat.icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-muted mb-1">{stat.label}</p>
|
||||
<h3 className="font-outfit text-2xl font-bold text-text-main tracking-tight">{stat.value}</h3>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
Calendar,
|
||||
User,
|
||||
MapPin,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
PenTool
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AssetVoucherCardProps {
|
||||
type: 'CHECK_OUT' | 'CHECK_IN';
|
||||
id: string;
|
||||
date: string;
|
||||
project: string;
|
||||
user: string;
|
||||
assets: any[];
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디지털 전표(Voucher) UI 컴포넌트
|
||||
* 반입/반출 증빙 자료를 시각화합니다.
|
||||
*/
|
||||
export default function AssetVoucherCard({
|
||||
type,
|
||||
id,
|
||||
date,
|
||||
project,
|
||||
user,
|
||||
assets,
|
||||
signature
|
||||
}: AssetVoucherCardProps) {
|
||||
const isCheckout = type === 'CHECK_OUT';
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-[32px] shadow-modal overflow-hidden max-w-2xl mx-auto">
|
||||
{/* Voucher Header */}
|
||||
<div className={`p-8 ${isCheckout ? 'bg-orange-500' : 'bg-primary'} text-white`}>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="bg-white/20 p-3 rounded-2xl backdrop-blur-md">
|
||||
<FileText className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest opacity-70">Digital Voucher</p>
|
||||
<h3 className="text-2xl font-bold tracking-tight">{id}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`px-3 py-1 rounded-full text-[10px] font-bold bg-white ${isCheckout ? 'text-orange-500' : 'text-primary'}`}>
|
||||
{isCheckout ? 'CHECK-OUT (반출/배정)' : 'CHECK-IN (반입/회수)'}
|
||||
</div>
|
||||
<span className="text-xs opacity-70 flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voucher Body */}
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 gap-8 pb-8 border-b border-border">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase tracking-wider">대상 프로젝트</p>
|
||||
<p className="text-sm font-bold text-text-main">{project}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase tracking-wider">책임 사용자</p>
|
||||
<p className="text-sm font-bold text-text-main">{user}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset List */}
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase tracking-wider mb-4">대상 자산 목록 ({assets.length}건)</p>
|
||||
<div className="space-y-3">
|
||||
{assets.map((asset, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-2xl border border-border">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center mr-3 ${isCheckout ? 'bg-orange-100' : 'bg-blue-100'}`}>
|
||||
{isCheckout ? <ArrowUpRight className="w-4 h-4 text-orange-600" /> : <ArrowDownLeft className="w-4 h-4 text-blue-600" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-text-main">{asset.model}</p>
|
||||
<p className="text-[10px] font-mono text-text-muted">{asset.sn}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-bold text-text-muted bg-surface px-2 py-0.5 rounded border border-border uppercase">
|
||||
{asset.status || 'Normal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Declaration & Signature */}
|
||||
<div className="pt-8 border-t border-border flex items-end justify-between">
|
||||
<div className="max-w-[300px]">
|
||||
<div className="flex items-center text-emerald-600 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 mr-1.5" />
|
||||
<span className="text-[11px] font-bold">인수/반납 확인 완료</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted leading-relaxed italic">
|
||||
본 전표에 기재된 장비의 {isCheckout ? '인수' : '반납'}를 확인하며, {isCheckout ? '파손 및 분실 시 회사 규정에 따른 책임' : '재고 복귀'}를 승인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-32 h-16 bg-gray-50 rounded-xl border border-dashed border-border flex items-center justify-center mb-1 relative">
|
||||
{signature ? (
|
||||
<span className="font-cursive text-xl text-text-main">{signature}</span>
|
||||
) : (
|
||||
<PenTool className="w-5 h-5 text-gray-200" />
|
||||
)}
|
||||
<div className="absolute bottom-1 right-2 text-[8px] text-gray-300 font-bold uppercase">Signature</div>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase">{user}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Branding */}
|
||||
<div className="px-8 py-4 bg-gray-50 border-t border-border flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold text-gray-300 uppercase tracking-widest">SR-SYSTEM ITAM INFRA</p>
|
||||
<div className="flex space-x-4">
|
||||
<button className="text-[10px] font-bold text-text-muted hover:text-text-main transition-colors">Print PDF</button>
|
||||
<button className="text-[10px] font-bold text-text-muted hover:text-text-main transition-colors">Share Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import { X, Camera, AlertCircle, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
|
||||
interface BarcodeScannerProps {
|
||||
onScan: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BarcodeScanner({ onScan, onClose }: BarcodeScannerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [zoomMax, setZoomMax] = useState(3);
|
||||
const [supportsZoom, setSupportsZoom] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
const scannerRef = useRef<Html5Qrcode | null>(null);
|
||||
const trackRef = useRef<MediaStreamTrack | null>(null);
|
||||
|
||||
const applyZoom = useCallback(async (level: number, video?: HTMLVideoElement | null) => {
|
||||
const v = video || (document.querySelector('#sr-barcode-scanner video') as HTMLVideoElement | null);
|
||||
if (v && v.srcObject) {
|
||||
if (trackRef.current && supportsZoom) {
|
||||
try { await trackRef.current.applyConstraints({ advanced: [{ zoom: level }] as any }); } catch { }
|
||||
}
|
||||
(v as HTMLElement).style.transform = `scale(${level})`;
|
||||
}
|
||||
}, [supportsZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
// StrictMode guard: skip second effect run
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const id = 'sr-barcode-scanner';
|
||||
el.id = id;
|
||||
el.innerHTML = '';
|
||||
|
||||
let cancelled = false;
|
||||
const s = new Html5Qrcode(id);
|
||||
scannerRef.current = s;
|
||||
|
||||
const startWithFacing = async (facingMode: 'environment' | 'user') => {
|
||||
await s.start(
|
||||
{ facingMode },
|
||||
{ fps: 30 },
|
||||
(decodedText) => {
|
||||
if (cancelled) return;
|
||||
try { s.stop(); } catch { }
|
||||
onScan(decodedText);
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
// Set up zoom after camera starts
|
||||
setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
const video = el.querySelector('video');
|
||||
if (video && video.srcObject) {
|
||||
(video as HTMLElement).style.transition = 'transform 0.2s';
|
||||
(video as HTMLElement).style.transformOrigin = 'center center';
|
||||
const track = (video.srcObject as MediaStream).getVideoTracks()[0];
|
||||
if (track) {
|
||||
trackRef.current = track;
|
||||
const caps = track.getCapabilities() as any;
|
||||
if (caps?.zoom) { setSupportsZoom(true); setZoomMax(caps.zoom.max || 3); }
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
startWithFacing('environment').catch(() => {
|
||||
return startWithFacing('user');
|
||||
}).catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const isIOS = navigator.userAgent.includes('iPhone') || navigator.userAgent.includes('iPad');
|
||||
if (msg.includes('NotAllowed') || msg.includes('Permission')) {
|
||||
setError(isIOS ? 'Camera denied. Settings → Safari → Camera → Allow.' : 'Camera permission denied.');
|
||||
} else if (msg.includes('NotFound')) {
|
||||
setError('No camera found on this device.');
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
try { s.stop(); } catch { }
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const newZoom = Math.min(zoom + 0.5, zoomMax);
|
||||
setZoom(newZoom);
|
||||
applyZoom(newZoom);
|
||||
};
|
||||
const handleZoomOut = () => {
|
||||
const newZoom = Math.max(zoom - 0.5, 0.5);
|
||||
setZoom(newZoom);
|
||||
applyZoom(newZoom);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="w-4 h-5 text-primary" />
|
||||
<span className="text-sm font-bold text-slate-900">Scan Barcode</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-slate-100 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="p-6 text-center space-y-4">
|
||||
<div className="w-12 h-12 mx-auto rounded-full bg-red-50 flex items-center justify-center">
|
||||
<AlertCircle className="w-6 h-6 text-red-500" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{error}</p>
|
||||
<button onClick={onClose} className="px-6 py-2.5 bg-primary text-white rounded-xl text-sm font-medium">Close</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative w-full aspect-[4/3] overflow-hidden">
|
||||
<div ref={containerRef} className="absolute inset-0 bg-black" />
|
||||
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 rounded-full px-2 py-1.5 z-10">
|
||||
<button onClick={handleZoomOut} className="p-1 text-white/70 hover:text-white" type="button"><ZoomOut className="w-4 h-4" /></button>
|
||||
<span className="text-[10px] text-white font-mono min-w-[24px] text-center">{zoom.toFixed(1)}x</span>
|
||||
<button onClick={handleZoomIn} className="p-1 text-white/70 hover:text-white" type="button"><ZoomIn className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 text-center py-3 px-4">Position the barcode or QR code within the scanning area</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
FileSpreadsheet,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
RefreshCcw,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import equipmentService from '@/services/equipmentService';
|
||||
import type { Equipment, EquipmentType } from '@/types';
|
||||
|
||||
interface ParsedItem {
|
||||
row: number;
|
||||
data: Partial<Equipment>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
row: number;
|
||||
status: 'Error' | 'Warning' | 'Success';
|
||||
field: string;
|
||||
value: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대량 등록 (엑셀) 뷰 컴포넌트
|
||||
* 엑셀 업로드 및 데이터 유효성 검증 프로세스를 제공합니다.
|
||||
*/
|
||||
export default function BatchRegistrationView() {
|
||||
const [isUploaded, setIsUploaded] = useState(false);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [parsedItems, setParsedItems] = useState<ParsedItem[]>([]);
|
||||
const [validationResults, setValidationResults] = useState<ValidationResult[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [successCount, setSuccessCount] = useState(0);
|
||||
const [errorCount, setErrorCount] = useState(0);
|
||||
|
||||
const typeMapping: Record<string, EquipmentType> = {
|
||||
'LAPTOP': 'LAPTOP',
|
||||
'MONITOR': 'MONITOR',
|
||||
'TEST_DEVICE': 'TEST_DEVICE',
|
||||
'ACCESSORY': 'ACCESSORY',
|
||||
};
|
||||
|
||||
const parseInput = () => {
|
||||
const lines = inputText.trim().split('\n');
|
||||
const items: ParsedItem[] = [];
|
||||
const errors: ValidationResult[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const row = index + 1;
|
||||
const parts = line.split(',').map(p => p.trim());
|
||||
|
||||
if (parts.length < 7) {
|
||||
errors.push({
|
||||
row,
|
||||
status: 'Error',
|
||||
field: 'Format',
|
||||
value: line,
|
||||
message: '필수 항목이 부족합니다 (7 개 항목 필요)',
|
||||
});
|
||||
items.push({ row, data: {}, error: 'Invalid format' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [equipmentType, manufacturer, modelName, serialNumber, assetCode, location, purchaseDate] = parts;
|
||||
|
||||
if (!typeMapping[equipmentType.toUpperCase()]) {
|
||||
errors.push({
|
||||
row,
|
||||
status: 'Error',
|
||||
field: 'EquipmentType',
|
||||
value: equipmentType,
|
||||
message: '허용되지 않는 장비 유형입니다 (LAPTOP, MONITOR, TEST_DEVICE, ACCESSORY)',
|
||||
});
|
||||
items.push({ row, data: {}, error: 'Invalid type' });
|
||||
return;
|
||||
}
|
||||
|
||||
items.push({
|
||||
row,
|
||||
data: {
|
||||
equipmentType: typeMapping[equipmentType.toUpperCase()],
|
||||
manufacturer,
|
||||
modelName,
|
||||
serialNumber,
|
||||
assetCode,
|
||||
specifications: { location },
|
||||
status: 'AVAILABLE',
|
||||
purchaseDate,
|
||||
warrantyExpDate: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (errors.length === 0) {
|
||||
errors.push({
|
||||
row,
|
||||
status: 'Success',
|
||||
field: 'All',
|
||||
value: '-',
|
||||
message: '정상',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setParsedItems(items);
|
||||
setValidationResults(errors);
|
||||
setIsUploaded(true);
|
||||
};
|
||||
|
||||
const handleBatchRegister = async () => {
|
||||
setIsProcessing(true);
|
||||
setSuccessCount(0);
|
||||
setErrorCount(0);
|
||||
|
||||
const validItems = parsedItems.filter(item => !item.error);
|
||||
|
||||
for (const item of validItems) {
|
||||
try {
|
||||
await equipmentService.create(item.data);
|
||||
setSuccessCount(prev => prev + 1);
|
||||
} catch (error: any) {
|
||||
setErrorCount(prev => prev + 1);
|
||||
setValidationResults(prev => prev.map(res =>
|
||||
res.row === item.row
|
||||
? { ...res, status: 'Error', message: error.response?.data?.message || '등록 실패' }
|
||||
: res
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsUploaded(false);
|
||||
setInputText('');
|
||||
setParsedItems([]);
|
||||
setValidationResults([]);
|
||||
setSuccessCount(0);
|
||||
setErrorCount(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* Step 1: Template & Upload */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Template Card */}
|
||||
<div className="bg-white p-8 rounded-[32px] border border-slate-200 shadow-sm flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-2xl bg-emerald-50 flex items-center justify-center mb-6">
|
||||
<Download className="w-6 h-6 text-emerald-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-2">1. 템플릿 양식 다운로드</h3>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
정해진 엑셀 양식에 맞춰 데이터를 작성해야 오류 없이 일괄 등록이 가능합니다. 필수 입력 항목인 [모델명, 자산유형, 시리얼] 을 반드시 포함해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
<button className="mt-8 flex items-center justify-center w-full py-4 border-2 border-emerald-100 bg-emerald-50/50 text-emerald-600 rounded-2xl text-xs font-bold hover:bg-emerald-100 transition-all">
|
||||
<FileSpreadsheet className="w-4 h-4 mr-2" />
|
||||
표준 등록 템플릿 (Excel) 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload Card - Textarea Input */}
|
||||
<div className="bg-white p-8 rounded-[32px] border border-slate-200 shadow-sm flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-50 flex items-center justify-center mb-6">
|
||||
<Upload className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-2">2. 데이터 입력</h3>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mb-4">
|
||||
각 줄마다 장비를 입력하세요: type,manufacturer,model,serial,assetCode,location,purchaseDate
|
||||
</p>
|
||||
<textarea
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder={`LAPTOP,Samsung,NT500,SN-001,AST-2025-001,Seoul,2025-01-15
|
||||
MONITOR,LG,27UP850,SN-002,AST-2025-002,Busan,2025-02-20`}
|
||||
className="w-full h-40 p-4 bg-slate-50 border border-slate-200 rounded-xl text-xs font-mono focus:ring-2 focus:ring-primary/20 outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={parseInput}
|
||||
disabled={!inputText.trim()}
|
||||
className="mt-4 flex items-center justify-center w-full py-4 bg-primary text-white rounded-2xl text-xs font-bold hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
데이터 파싱 및 검증
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Validation Results (Visible only after upload) */}
|
||||
{isUploaded && (
|
||||
<div className="bg-white rounded-[32px] border border-slate-200 shadow-lg overflow-hidden animate-in slide-in-from-top-4 duration-500">
|
||||
<div className="px-8 py-6 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h3 className="text-sm font-bold text-slate-900 flex items-center">
|
||||
<CheckCircle2 className="w-4 h-4 mr-2 text-emerald-500" />
|
||||
데이터 유효성 검증 결과
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-500 text-[10px] font-bold rounded border border-red-100">
|
||||
Error {validationResults.filter(r => r.status === 'Error').length}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-500 text-[10px] font-bold rounded border border-emerald-100">
|
||||
Success {validationResults.filter(r => r.status === 'Success').length}
|
||||
</span>
|
||||
{successCount > 0 && (
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-500 text-[10px] font-bold rounded border border-blue-100">
|
||||
Registered {successCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleReset} className="flex items-center text-[11px] font-bold text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<RefreshCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
다시 업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-100 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
<th className="px-8 py-4">행 (Row)</th>
|
||||
<th className="px-8 py-4">검증 상태</th>
|
||||
<th className="px-8 py-4">대상 필드</th>
|
||||
<th className="px-8 py-4">입력 값</th>
|
||||
<th className="px-8 py-4">메시지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{validationResults.map((res, idx) => (
|
||||
<tr key={idx} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-8 py-4 text-xs font-mono font-bold text-slate-500">{res.row}</td>
|
||||
<td className="px-8 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold border ${
|
||||
res.status === 'Error' ? 'bg-red-50 text-red-500 border-red-100' :
|
||||
res.status === 'Warning' ? 'bg-orange-50 text-orange-500 border-orange-100' :
|
||||
'bg-emerald-50 text-emerald-500 border-emerald-100'
|
||||
}`}>
|
||||
{res.status === 'Error' && <AlertTriangle className="w-3 h-3 mr-1.5" />}
|
||||
{res.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-4 text-xs font-bold text-slate-700">{res.field}</td>
|
||||
<td className="px-8 py-4 text-xs font-mono text-slate-500">{res.value}</td>
|
||||
<td className="px-8 py-4 text-xs text-slate-600">{res.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
<span className="font-bold text-red-500">주의:</span> 오류가 있는 행을 모두 수정해야 일괄 등록이 가능합니다.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleBatchRegister}
|
||||
disabled={isProcessing || validationResults.some(r => r.status === 'Error')}
|
||||
className="bg-primary text-white px-8 py-3 rounded-2xl text-xs font-bold hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<RefreshCcw className="w-4 h-4 mr-2 animate-spin" />
|
||||
등록 중... ({successCount}건 완료)
|
||||
</>
|
||||
) : (
|
||||
`일괄 등록 완료 (${parsedItems.filter(i => !i.error).length}건)`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Tips */}
|
||||
<div className="p-6 bg-slate-900 rounded-[32px] text-white flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold">대량 등록 팁</h4>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">시리얼 번호가 중복될 경우 시스템이 자동으로 기존 자산 업데이트 또는 오류로 분류합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Camera,
|
||||
Scan,
|
||||
CheckCircle2,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
History,
|
||||
Monitor,
|
||||
Cpu,
|
||||
Database,
|
||||
Image as ImageIcon,
|
||||
AlertCircle,
|
||||
QrCode
|
||||
} from 'lucide-react';
|
||||
import equipmentService from '@/services/equipmentService';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import type { Equipment, EquipmentType } from '@/types';
|
||||
import AssetLabel from './AssetLabel';
|
||||
import BarcodeScanner from './BarcodeScanner';
|
||||
|
||||
interface SingleRegistrationFormProps {
|
||||
onRegisterSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건/연속 등록 폼 컴포넌트
|
||||
* 전문가 자문을 반영한 지능형 필드와 스캔 기능을 포함합니다.
|
||||
*/
|
||||
export default function SingleRegistrationForm({ onRegisterSuccess }: SingleRegistrationFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [tempList, setTempList] = useState<any[]>([]);
|
||||
const [uploadedImages, setUploadedImages] = useState<{ url: string; name: string }[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [labelAsset, setLabelAsset] = useState<{ assetCode: string; modelName: string; serialNumber: string; manufacturer: string; purchaseDate: string } | null>(null);
|
||||
const [showScanner, setShowScanner] = useState(false);
|
||||
|
||||
const { codes: equipmentTypeCodes, loading: typeCodesLoading } = useCommonCodes('EQUIPMENT_TYPE');
|
||||
|
||||
const [formData, setFormData] = useState<Partial<Equipment>>({
|
||||
equipmentType: 'LAPTOP',
|
||||
manufacturer: '',
|
||||
modelName: '',
|
||||
serialNumber: '',
|
||||
assetCode: '',
|
||||
specifications: {} as Record<string, string>,
|
||||
status: 'AVAILABLE',
|
||||
purchaseDate: '',
|
||||
warrantyExpDate: '',
|
||||
});
|
||||
|
||||
const typeMapping: Record<string, EquipmentType> = {
|
||||
'LAPTOP': 'LAPTOP',
|
||||
'DESKTOP': 'ACCESSORY',
|
||||
'SERVER': 'ACCESSORY',
|
||||
'MONITOR': 'MONITOR',
|
||||
};
|
||||
|
||||
const getSmartSpec = (type: string) => {
|
||||
switch (type) {
|
||||
case 'LAPTOP': return JSON.stringify({ os: 'Win 11 Pro', ram: '16GB', storage: '512GB' });
|
||||
case 'DESKTOP': return JSON.stringify({ os: 'Win 11 Pro', ram: '32GB', storage: '1TB' });
|
||||
case 'SERVER': return JSON.stringify({ os: 'Linux Ubuntu 22.04', ram: '64GB', storage: '2TB' });
|
||||
case 'MONITOR': return JSON.stringify({ size: '27inch', resolution: '4K UHD' });
|
||||
default: return '{}';
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof Equipment, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files?.length) return;
|
||||
setUploadingImage(true);
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch('/api/v1/files/upload', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setUploadedImages(prev => [...prev, { url: data.data, name: file.name }]);
|
||||
}
|
||||
} catch {
|
||||
// skip failed uploads
|
||||
}
|
||||
}
|
||||
setUploadingImage(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeImage = (idx: number) => {
|
||||
setUploadedImages(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
...formData,
|
||||
equipmentType: typeMapping[formData.equipmentType as string] || 'LAPTOP',
|
||||
specifications: (() => {
|
||||
const spec = formData.specifications;
|
||||
if (typeof spec === 'string') {
|
||||
try { return JSON.parse(spec); } catch { return {} as Record<string, string>; }
|
||||
}
|
||||
return spec || {};
|
||||
})(),
|
||||
};
|
||||
if (uploadedImages.length > 0) {
|
||||
payload.images = JSON.stringify(uploadedImages);
|
||||
}
|
||||
|
||||
await equipmentService.create(payload);
|
||||
|
||||
const p = payload as Record<string, string>;
|
||||
setLabelAsset({
|
||||
assetCode: p.assetCode || '',
|
||||
modelName: p.modelName || '',
|
||||
serialNumber: p.serialNumber || '',
|
||||
manufacturer: p.manufacturer || '',
|
||||
purchaseDate: p.purchaseDate || '',
|
||||
});
|
||||
|
||||
setTempList(prev => [{
|
||||
id: formData.assetCode || `TMP-${Math.floor(Math.random()*1000)}`,
|
||||
type: formData.equipmentType,
|
||||
model: formData.modelName
|
||||
}, ...prev]);
|
||||
|
||||
onRegisterSuccess();
|
||||
|
||||
setFormData({
|
||||
equipmentType: 'LAPTOP',
|
||||
manufacturer: '',
|
||||
modelName: '',
|
||||
serialNumber: '',
|
||||
assetCode: '',
|
||||
specifications: {} as Record<string, string>,
|
||||
status: 'AVAILABLE',
|
||||
});
|
||||
setUploadedImages([]);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.response?.data?.message || '등록에 실패했습니다. 다시 시도해 주세요.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
|
||||
{/* Left: Input Form (8 cols) */}
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
<form onSubmit={handleRegister} className="bg-white p-8 rounded-[32px] border border-slate-200 shadow-sm space-y-8">
|
||||
|
||||
{/* Section 1: Scan & Identity */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest flex items-center">
|
||||
<Scan className="w-4 h-4 mr-2" />
|
||||
Scan & Identification
|
||||
</h3>
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* Camera Scanner Button */}
|
||||
<button type="button" onClick={() => setShowScanner(true)} className="relative w-full sm:w-48 h-24 sm:h-32 rounded-2xl sm:rounded-[24px] border-2 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all border-slate-200 bg-slate-50 hover:border-primary hover:bg-primary/5">
|
||||
<Camera className="w-6 h-6 sm:w-8 sm:h-8 text-slate-300 mb-1 sm:mb-2" />
|
||||
<p className="text-[11px] sm:text-[10px] font-bold text-slate-400">바코드/QR 스캔</p>
|
||||
<p className="text-[9px] sm:text-[8px] text-slate-300 mt-0.5 sm:mt-1">카메라로 인식</p>
|
||||
</button>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-slate-500 ml-1">자산 유형</label>
|
||||
<div className="flex space-x-2">
|
||||
{typeCodesLoading ? (
|
||||
<span className="text-xs text-slate-400">Loading...</span>
|
||||
) : (
|
||||
equipmentTypeCodes.map((type) => (
|
||||
<button
|
||||
key={type.codeValue}
|
||||
type="button"
|
||||
onClick={() => handleInputChange('equipmentType', type.codeValue)}
|
||||
className={`flex-1 py-2.5 rounded-xl text-xs font-bold border transition-all ${
|
||||
formData.equipmentType === type.codeValue ? 'bg-slate-900 text-white border-slate-900 shadow-md' : 'bg-white text-slate-400 border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{type.codeName}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-slate-500 ml-1">시리얼 번호 (S/N)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="스캔 시 자동 입력"
|
||||
value={formData.serialNumber}
|
||||
onChange={(e) => handleInputChange('serialNumber', e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-slate-500 ml-1 flex items-center">
|
||||
하드웨어 사양
|
||||
<span className="ml-2 text-[9px] text-primary bg-primary/10 px-1 rounded">Smart Default</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof formData.specifications === 'string' ? formData.specifications : JSON.stringify(formData.specifications)}
|
||||
onChange={(e) => handleInputChange('specifications', e.target.value)}
|
||||
className="w-full px-4 py-3 bg-indigo-50/30 border border-indigo-100 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Details */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-slate-500 ml-1">제조사</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="예: Samsung, LG, Apple"
|
||||
value={formData.manufacturer}
|
||||
onChange={(e) => handleInputChange('manufacturer', e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-slate-500 ml-1">모델명</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="예: MacBook Pro 14"
|
||||
value={formData.modelName}
|
||||
onChange={(e) => handleInputChange('modelName', e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-slate-500 ml-1">자산 코드</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="예: AST-2025-001"
|
||||
value={formData.assetCode}
|
||||
onChange={(e) => handleInputChange('assetCode', e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-slate-500 ml-1">구매일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.purchaseDate}
|
||||
onChange={(e) => handleInputChange('purchaseDate', e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-slate-500 ml-1">무상보증기간</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.warrantyExpDate}
|
||||
onChange={(e) => handleInputChange('warrantyExpDate', e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Equipment Photos (다중 업로드) */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest flex items-center">
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
Equipment Photos
|
||||
<span className="ml-2 text-[9px] text-primary bg-primary/10 px-1.5 py-0.5 rounded">Multiple</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{uploadedImages.map((img, i) => (
|
||||
<div key={i} className="relative group w-24 h-24 rounded-2xl overflow-hidden border border-slate-200">
|
||||
<img src={img.url} alt={img.name} className="w-full h-full object-cover" />
|
||||
<button type="button" onClick={() => removeImage(i)} className="absolute top-1 right-1 w-5 h-5 bg-black/50 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all">
|
||||
<Trash2 className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/50 to-transparent p-1">
|
||||
<p className="text-[8px] text-white truncate">{img.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<label className="w-24 h-24 border-2 border-dashed border-slate-200 rounded-2xl flex flex-col items-center justify-center hover:bg-slate-50 hover:border-slate-300 transition-all cursor-pointer">
|
||||
{uploadingImage ? (
|
||||
<div className="w-5 h-5 border-2 border-slate-300 border-t-primary rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle className="w-6 h-6 text-slate-300 mb-1" />
|
||||
<p className="text-[8px] font-bold text-slate-400 text-center px-1">Add Photo</p>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} disabled={uploadingImage} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMessage && (
|
||||
<div className="p-4 bg-red-50 border border-red-100 rounded-xl flex items-center">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 mr-2" />
|
||||
<p className="text-xs text-red-600 font-medium">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button (Continuous Mode UX) */}
|
||||
<div className="pt-6 border-t border-slate-100 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-400 flex items-center">
|
||||
<AlertCircle className="w-3.5 h-3.5 mr-1.5" />
|
||||
저장 시 자동으로 다음 장비 등록 상태로 전환됩니다.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({
|
||||
equipmentType: 'LAPTOP',
|
||||
manufacturer: '',
|
||||
modelName: '',
|
||||
serialNumber: '',
|
||||
assetCode: '',
|
||||
specifications: {} as Record<string, string>,
|
||||
status: 'AVAILABLE',
|
||||
purchaseDate: '',
|
||||
warrantyExpDate: '',
|
||||
})}
|
||||
className="px-6 py-3 rounded-2xl text-sm font-bold text-slate-500 hover:bg-slate-100 transition-all"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-slate-900 text-white px-10 py-3 rounded-2xl text-sm font-bold shadow-xl shadow-slate-200 hover:bg-slate-800 transition-all flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
{isSubmitting ? '등록 중...' : '저장 후 계속 (Next)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right: Temp Registered List (4 cols) */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<div className="bg-white p-6 rounded-[32px] border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-sm font-bold text-slate-900 flex items-center">
|
||||
<History className="w-4 h-4 mr-2 text-primary" />
|
||||
임시 등록 리스트
|
||||
</h3>
|
||||
<span className="text-[11px] font-bold bg-slate-100 px-2 py-0.5 rounded-full text-slate-500">
|
||||
{tempList.length}건 대기
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{tempList.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-slate-300">
|
||||
<Database className="w-10 h-10 mb-2 opacity-20" />
|
||||
<p className="text-xs font-bold">아직 등록된 자산이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
tempList.map((item, idx) => (
|
||||
<div key={idx} className="group p-4 bg-slate-50 rounded-2xl border border-slate-100 flex items-center justify-between hover:bg-white hover:border-primary/20 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center mr-3">
|
||||
<Cpu className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400">{item.id}</p>
|
||||
<p className="text-xs font-bold text-slate-900">{item.model}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="p-2 opacity-0 group-hover:opacity-100 text-slate-300 hover:text-red-500 transition-all">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tempList.length > 0 && (
|
||||
<button className="w-full mt-6 bg-primary text-white py-3 rounded-2xl text-xs font-bold shadow-lg shadow-primary/20 hover:bg-primary-hover transition-all">
|
||||
최종 일괄 전송 ({tempList.length}건)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pro Tip Card */}
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 p-6 rounded-[32px] text-white">
|
||||
<h4 className="text-xs font-bold text-primary mb-2 uppercase tracking-widest">Pro Tip</h4>
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
바코드 스캔 시 모델 정보가 DB에 있다면 모든 사양 정보가 자동으로 채워집니다. 시리얼 번호 중복 여부도 실시간으로 체크됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{showScanner && (
|
||||
<BarcodeScanner
|
||||
onScan={(value) => {
|
||||
if (value.length >= 8 && /^[A-Za-z0-9-]+$/.test(value)) {
|
||||
if (value.startsWith('HQ-') || value.startsWith('AST-')) {
|
||||
setFormData((prev) => ({ ...prev, assetCode: value }));
|
||||
} else {
|
||||
setFormData((prev) => ({ ...prev, serialNumber: value }));
|
||||
}
|
||||
}
|
||||
setShowScanner(false);
|
||||
}}
|
||||
onClose={() => setShowScanner(false)}
|
||||
/>
|
||||
)}
|
||||
{labelAsset && (
|
||||
<AssetLabel
|
||||
assetCode={labelAsset.assetCode}
|
||||
modelName={labelAsset.modelName}
|
||||
serialNumber={labelAsset.serialNumber}
|
||||
manufacturer={labelAsset.manufacturer}
|
||||
purchaseDate={labelAsset.purchaseDate}
|
||||
onClose={() => setLabelAsset(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { getCookie } from '@/lib/cookie';
|
||||
|
||||
const publicPaths = ['/login'];
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, fetchMe, tryRefresh, accessToken } = useAuthStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicPaths.includes(pathname)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
const token = accessToken || localStorage.getItem('accessToken') || getCookie('accessToken');
|
||||
|
||||
if (!token) {
|
||||
const hasRefresh = localStorage.getItem('refreshToken');
|
||||
if (hasRefresh) {
|
||||
const renewed = await tryRefresh();
|
||||
if (renewed) {
|
||||
await fetchMe().catch(() => {
|
||||
router.replace('/login');
|
||||
return;
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
await fetchMe().catch(() => {
|
||||
router.replace('/login');
|
||||
return;
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
init();
|
||||
}, [pathname]);
|
||||
|
||||
if (publicPaths.includes(pathname)) return <>{children}</>;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-canvas">
|
||||
<div className="text-center">
|
||||
<ShieldAlert className="w-10 h-10 text-primary mx-auto mb-3 animate-pulse" />
|
||||
<p className="text-text-muted text-sm">인증 확인 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Search, Calendar, MessageSquare, Bell, ChevronsRight, ChevronsLeft, Plus, Menu } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||
import NotificationDropdown from './NotificationDropdown';
|
||||
import UserDropdown from './UserDropdown';
|
||||
|
||||
const Header = ({ toggleSidebar, isCollapsed, onOpenMobile }: { toggleSidebar: () => void, isCollapsed: boolean, onOpenMobile?: () => void }) => {
|
||||
const router = useRouter();
|
||||
const unreadCount = useNotificationStore((s) => s.unreadCount);
|
||||
const startPolling = useNotificationStore((s) => s.startPolling);
|
||||
const [searchVal, setSearchVal] = useState('');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchVal.trim()) {
|
||||
router.push(`/issues?search=${encodeURIComponent(searchVal.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6 shrink-0 z-30 shadow-sm">
|
||||
<div className="flex items-center flex-1">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="mr-4 lg:mr-6 text-text-muted hover:text-text-main transition-colors p-1 hover:bg-gray-100 rounded-lg lg:block hidden"
|
||||
>
|
||||
{isCollapsed ? <ChevronsRight className="w-5 h-5" /> : <ChevronsLeft className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenMobile?.()}
|
||||
className="mr-4 lg:mr-6 text-text-muted hover:text-text-main transition-colors p-1 hover:bg-gray-100 rounded-lg lg:hidden block"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<form onSubmit={handleSearch} className="relative w-full max-w-md">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchVal}
|
||||
onChange={(e) => setSearchVal(e.target.value)}
|
||||
placeholder="Search issues, assets, employees..."
|
||||
className="block w-full pl-10 pr-3 py-2 border-none rounded-xl bg-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all text-text-main"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button onClick={() => router.push('/calendar')} className="p-2 text-text-muted hover:bg-gray-100 rounded-full transition-colors" title="일정 보기">
|
||||
<Calendar className="w-5 h-5" />
|
||||
</button>
|
||||
<button className="p-2 text-text-muted hover:bg-gray-100 rounded-full transition-colors relative" title="메시지 보기">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className="p-2 text-text-muted hover:bg-gray-100 rounded-full transition-colors relative mr-2"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-status-critical-text text-white text-[10px] font-bold rounded-full flex items-center justify-center border-2 border-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<NotificationDropdown isOpen={isDropdownOpen} onClose={() => setIsDropdownOpen(false)} />
|
||||
</div>
|
||||
|
||||
<Link href="/issues" className="bg-primary hover:bg-primary-hover text-white px-4 py-2 rounded-xl text-xs font-semibold shadow-soft transition-all flex items-center">
|
||||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||||
New SR
|
||||
</Link>
|
||||
|
||||
<div className="h-8 w-px bg-gray-200"></div>
|
||||
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Sidebar from './Sidebar';
|
||||
import Header from './Header';
|
||||
import AuthGuard from './AuthGuard';
|
||||
import MobileBottomNav from './MobileBottomNav';
|
||||
|
||||
export default function MainShell({ children }: { children: React.ReactNode }) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const toggleSidebar = () => setIsCollapsed(!isCollapsed);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="h-full flex overflow-hidden">
|
||||
{/* Desktop Sidebar - hidden on mobile */}
|
||||
<div className="hidden lg:block">
|
||||
<Sidebar isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar overlay (drawer) */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden" onClick={() => setMobileMenuOpen(false)}>
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[250px] bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<Sidebar isCollapsed={false} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-canvas">
|
||||
<Header toggleSidebar={toggleSidebar} isCollapsed={isCollapsed} onOpenMobile={() => setMobileMenuOpen(true)} />
|
||||
|
||||
<main className="flex-1 overflow-auto pb-16 lg:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile bottom navigation */}
|
||||
<MobileBottomNav />
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { LayoutDashboard, Ticket, Link2, Monitor, FileText } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Issues', href: '/issues', icon: Ticket },
|
||||
{ name: 'Dispatch', href: '/dispatch', icon: Link2 },
|
||||
{ name: 'ITAM', href: '/itam', icon: Monitor },
|
||||
{ name: 'Reports', href: '/work-reports', icon: FileText },
|
||||
];
|
||||
|
||||
export default function MobileBottomNav() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav className="fixed bottom-0 inset-x-0 z-50 bg-white border-t border-border flex items-center justify-around py-1 safe-area-bottom lg:hidden shadow-[0_-2px_10px_rgba(0,0,0,0.06)]">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
return (
|
||||
<Link key={item.name} href={item.href} className={`flex flex-col items-center py-1 px-3 rounded-xl transition-colors ${isActive ? 'text-primary' : 'text-text-muted'}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium mt-0.5">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Bell, Check, AlertCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
const formatTimeAgo = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return '방금 전';
|
||||
if (diffMin < 60) return `${diffMin}분 전`;
|
||||
if (diffHour < 24) return `${diffHour}시간 전`;
|
||||
if (diffDay < 7) return `${diffDay}일 전`;
|
||||
if (diffDay < 30) return `${Math.floor(diffDay / 7)}주 전`;
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'SR_TICKET':
|
||||
return <AlertCircle className="w-4 h-4 text-blue-500" />;
|
||||
case 'SR_UPDATE':
|
||||
return <Info className="w-4 h-4 text-indigo-500" />;
|
||||
case 'SLA_WARNING':
|
||||
return <AlertTriangle className="w-4 h-4 text-amber-500" />;
|
||||
case 'REPORT_APPROVED':
|
||||
case 'REPORT_REJECTED':
|
||||
return <Check className="w-4 h-4 text-emerald-500" />;
|
||||
case 'EQUIPMENT':
|
||||
return <Bell className="w-4 h-4 text-purple-500" />;
|
||||
case 'ERROR':
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
default:
|
||||
return <Bell className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
interface NotificationDropdownProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NotificationDropdown = ({ isOpen, onClose }: NotificationDropdownProps) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const notifications = useNotificationStore((s) => s.notifications);
|
||||
const markAsRead = useNotificationStore((s) => s.markAsRead);
|
||||
const fetchNotifications = useNotificationStore((s) => s.fetchNotifications);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
fetchNotifications();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose, fetchNotifications]);
|
||||
|
||||
const handleNotificationClick = async (id: number) => {
|
||||
await markAsRead(id);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
const unreadIds = notifications.filter((n) => !n.isRead).map((n) => n.notificationId);
|
||||
for (const id of unreadIds) {
|
||||
await markAsRead(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const unreadNotifications = notifications.filter((n) => !n.isRead);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 top-full mt-2 w-96 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-50"
|
||||
style={{ maxHeight: '500px' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-gray-50">
|
||||
<h3 className="text-sm font-semibold text-text-main">알림</h3>
|
||||
{unreadNotifications.length > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-xs text-primary hover:text-primary-hover font-medium transition-colors"
|
||||
>
|
||||
모두 읽음
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notification List */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: '420px' }}>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Bell className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-sm text-text-muted">알림이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-50">
|
||||
{notifications.map((notification) => (
|
||||
<li
|
||||
key={notification.notificationId}
|
||||
onClick={() => handleNotificationClick(notification.notificationId)}
|
||||
className={`px-4 py-3 cursor-pointer transition-colors hover:bg-gray-50 ${
|
||||
!notification.isRead ? 'bg-blue-50/50 hover:bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5">{getTypeIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p
|
||||
className={`text-sm font-medium truncate ${
|
||||
!notification.isRead ? 'text-text-main' : 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
{!notification.isRead && (
|
||||
<span className="w-2 h-2 bg-primary rounded-full shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted line-clamp-2 mb-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<span className="text-[10px] text-text-muted">
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-gray-100 bg-gray-50 text-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-xs text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationDropdown;
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Moon, RefreshCw, Clock } from 'lucide-react';
|
||||
|
||||
const BACKOFF_MS = [5_000, 10_000, 20_000, 40_000, 60_000];
|
||||
|
||||
export default function ParkingGuard({ children }: { children: React.ReactNode }) {
|
||||
const [status, setStatus] = useState<'loading' | 'parked' | 'online'>('loading');
|
||||
const backoffIdx = useRef(0);
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/health');
|
||||
if (!res.ok) { setStatus('parked'); return; }
|
||||
const body = await res.json();
|
||||
setStatus(body.data?.parked ? 'parked' : 'online');
|
||||
} catch {
|
||||
setStatus('parked');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth();
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
clearTimeout(timerRef.current);
|
||||
backoffIdx.current = 0;
|
||||
checkHealth();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [checkHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'parked') return;
|
||||
const delay = BACKOFF_MS[Math.min(backoffIdx.current, BACKOFF_MS.length - 1)];
|
||||
if (backoffIdx.current < BACKOFF_MS.length - 1) backoffIdx.current++;
|
||||
timerRef.current = window.setTimeout(checkHealth, delay);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [status, checkHealth]);
|
||||
|
||||
if (status === 'loading') return null;
|
||||
|
||||
if (status === 'parked') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-amber-50 flex items-center justify-center">
|
||||
<Moon className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-text-main mb-2 font-outfit">
|
||||
서버 점검 시간입니다
|
||||
</h2>
|
||||
|
||||
<div className="bg-amber-50 rounded-xl px-4 py-3 mb-5 text-sm text-amber-800">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>점검 시간: 토/일요일 · 평일 20:00~08:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
백엔드 서버가 절전 모드입니다. 서버가 기동되면 자동으로 접속됩니다.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => { backoffIdx.current = 0; checkHealth(); }}
|
||||
className="w-full flex items-center justify-center gap-2 bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold transition-all shadow-soft"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
서버 상태 확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Moon, Sun, Clock, Send, CheckCircle, Power } from 'lucide-react';
|
||||
import { useParkingStore } from '@/stores';
|
||||
|
||||
const RESUME_SCHEDULE = '월요일 08:00';
|
||||
|
||||
const ParkingOverlay = () => {
|
||||
const { parked, checking, wakeLoading, wakeSent, wakeMessage, requestWake, dismissWake } = useParkingStore();
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
if (checking || !parked) return null;
|
||||
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const hour = now.getHours();
|
||||
const isWeekend = day === 0 || day === 6;
|
||||
|
||||
const resumeTime = isWeekend
|
||||
? '월요일 08:00'
|
||||
: `오늘 ${hour < 8 ? '08:00' : '다음 영업일 08:00'}`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-amber-50 flex items-center justify-center">
|
||||
<Moon className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-text-main mb-2 font-outfit">
|
||||
서버 점검 시간입니다
|
||||
</h2>
|
||||
|
||||
<div className="bg-amber-50 rounded-xl px-4 py-3 mb-5 text-sm text-amber-800 space-y-1">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>예상 재기동: <strong>{resumeTime}</strong></span>
|
||||
</div>
|
||||
<div className="text-amber-600 text-xs">
|
||||
점검 시간: 토/일요일 (주말) · 평일 20:00~08:00
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!wakeSent ? (
|
||||
<>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
긴급하게 서버가 필요하면 아래 버튼으로 관리자에게 기동 요청을 보낼 수 있습니다.
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="요청 사유 (예: 긴급 SR 처리)"
|
||||
className="w-full px-4 py-2.5 border border-border rounded-xl text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent bg-white"
|
||||
maxLength={100}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => requestWake(reason || '긴급 서버 기동 요청')}
|
||||
disabled={wakeLoading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-soft"
|
||||
>
|
||||
{wakeLoading ? (
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Power className="w-4 h-4" />
|
||||
서버 기동 요청
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-emerald-50 rounded-xl px-4 py-5">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-emerald-800 font-medium mb-1">요청이 전송되었습니다</p>
|
||||
<p className="text-xs text-emerald-600">{wakeMessage}</p>
|
||||
<button
|
||||
onClick={dismissWake}
|
||||
className="mt-3 text-xs text-text-muted hover:text-text-main underline"
|
||||
>
|
||||
다시 요청하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 pt-4 border-t border-border">
|
||||
<a
|
||||
href="mailto:admin@example.com"
|
||||
className="text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
관리자 직접 연락: admin@example.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParkingOverlay;
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Ticket,
|
||||
Link2,
|
||||
Monitor,
|
||||
FileText,
|
||||
Plus,
|
||||
Lightbulb,
|
||||
ChevronDown,
|
||||
PlusCircle,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
History,
|
||||
BookOpen,
|
||||
Users,
|
||||
ClipboardCheck,
|
||||
CalendarDays,
|
||||
Settings,
|
||||
CheckSquare,
|
||||
Briefcase,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
/**
|
||||
* SR 시스템의 메인 사이드바 컴포넌트
|
||||
* ITAM 하위 메뉴를 포함하도록 고도화되었습니다.
|
||||
*/
|
||||
const Sidebar = ({ isCollapsed }: { isCollapsed?: boolean }) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isItamOpen, setIsItamOpen] = useState(pathname.startsWith('/itam'));
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const role = user?.roleCode;
|
||||
const isMgmt = role === 'MANAGER' || role === 'ADMIN';
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'SR Issues', href: '/issues', icon: Ticket },
|
||||
{ name: 'Dispatch', href: '/dispatch', icon: Link2 },
|
||||
];
|
||||
|
||||
const itamSubItems = [
|
||||
{ name: 'Asset Hub', href: '/itam', icon: Monitor },
|
||||
{ name: 'Register', href: '/itam/register', icon: PlusCircle },
|
||||
{ name: 'Check-out', href: '/itam/checkout', icon: ArrowUpRight },
|
||||
{ name: 'Check-in', href: '/itam/checkin', icon: ArrowDownLeft },
|
||||
{ name: 'History', href: '/itam', icon: History },
|
||||
];
|
||||
|
||||
const projects = [
|
||||
{ name: 'Samsung Pay SI', dot: 'bg-dot-green' },
|
||||
{ name: 'LG CNS SM', dot: 'bg-dot-orange' },
|
||||
{ name: 'Kakao Maintenance', dot: 'bg-dot-blue' },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-surface border-r border-border flex flex-col justify-between flex-shrink-0 transition-all duration-300 relative z-20 overflow-hidden ${
|
||||
isCollapsed ? 'w-[64px]' : 'w-[250px]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar">
|
||||
{/* 로고 영역 */}
|
||||
<div className={`h-16 flex items-center border-b border-border transition-all sticky top-0 bg-surface z-10 ${isCollapsed ? 'justify-center' : 'px-6'}`}>
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-tr from-primary to-purple-400 flex-shrink-0 shadow-lg shadow-primary/20"></div>
|
||||
{!isCollapsed && (
|
||||
<h1 className="font-outfit font-semibold text-lg text-text-main tracking-wide ml-3 whitespace-nowrap">SR System.</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 메인 메뉴 */}
|
||||
<div className={`py-6 ${isCollapsed ? 'px-1' : 'px-4'}`}>
|
||||
{!isCollapsed && (
|
||||
<p className="text-[10px] font-bold text-text-muted tracking-[0.12em] mb-4 px-2 uppercase whitespace-nowrap">Main Menu</p>
|
||||
)}
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
title={isCollapsed ? item.name : ''}
|
||||
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Projects link (management only) */}
|
||||
{isMgmt && (
|
||||
<Link
|
||||
href="/projects"
|
||||
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
pathname === '/projects'
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<Briefcase className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">Projects</span>}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* ITAM Group Menu */}
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => !isCollapsed && setIsItamOpen(!isItamOpen)}
|
||||
className={`w-full flex items-center justify-between rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
pathname.startsWith('/itam') && !isCollapsed
|
||||
? 'text-primary'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Monitor className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">ITAM (Assets)</span>}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-300 ${isItamOpen ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* ITAM Sub Items */}
|
||||
{isItamOpen && !isCollapsed && (
|
||||
<div className="pl-6 space-y-1 mt-1 animate-in slide-in-from-top-2 duration-300">
|
||||
{itamSubItems.map((sub) => {
|
||||
const SubIcon = sub.icon;
|
||||
const isActive = pathname === sub.href;
|
||||
return (
|
||||
<Link
|
||||
key={sub.name}
|
||||
href={sub.href}
|
||||
className={`flex items-center px-3 py-2 rounded-lg text-[11px] font-bold transition-all ${
|
||||
isActive
|
||||
? 'text-primary bg-indigo-50/50'
|
||||
: 'text-text-muted hover:text-text-main hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<SubIcon className="w-3.5 h-3.5 mr-2.5" />
|
||||
<span className="whitespace-nowrap">{sub.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/work-reports"
|
||||
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
pathname === '/work-reports'
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<FileText className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">Work Reports</span>}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/daily-reports"
|
||||
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
pathname === '/daily-reports'
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<ClipboardCheck className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">Daily Report</span>}
|
||||
</Link>
|
||||
|
||||
{isMgmt && (
|
||||
<Link
|
||||
href="/work-reports/review"
|
||||
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
pathname === '/work-reports/review'
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<CheckSquare className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">Report Review</span>}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{role === 'ADMIN' && (
|
||||
<Link
|
||||
href="/settings/codes"
|
||||
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
pathname === '/settings/codes'
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<Settings className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">Settings</span>}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/calendar"
|
||||
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
pathname === '/calendar'
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">Calendar</span>}
|
||||
</Link>
|
||||
|
||||
{isMgmt && (
|
||||
<Link
|
||||
href="/employees"
|
||||
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
|
||||
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
|
||||
} ${
|
||||
pathname === '/employees'
|
||||
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
|
||||
}`}
|
||||
>
|
||||
<Users className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
|
||||
{!isCollapsed && <span className="whitespace-nowrap">Employees</span>}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 내 프로젝트 */}
|
||||
<div className={`border-t border-border mt-4 pt-6 ${isCollapsed ? 'px-1 py-2' : 'px-4 py-2'}`}>
|
||||
<div className={`flex items-center justify-between mb-4 ${isCollapsed ? 'justify-center' : 'px-2'}`}>
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
<p className="text-[10px] font-bold text-text-muted tracking-[0.12em] uppercase whitespace-nowrap">My Projects</p>
|
||||
<button onClick={() => router.push('/dispatch')} className="text-text-muted hover:text-text-main">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase">MY</p>
|
||||
)}
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{projects.map((project) => (
|
||||
<Link
|
||||
key={project.name}
|
||||
href="/dispatch"
|
||||
title={isCollapsed ? project.name : ''}
|
||||
className={`flex items-center hover:bg-gray-50 rounded-xl text-xs font-bold text-text-muted transition-colors ${
|
||||
isCollapsed ? 'justify-center py-2' : 'px-3 py-2'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${project.dot} ${isCollapsed ? '' : 'mr-3'}`}></span>
|
||||
{!isCollapsed && <span className="whitespace-nowrap">{project.name}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 정보 카드 */}
|
||||
<div className={`${isCollapsed ? 'p-2' : 'p-6'}`}>
|
||||
{isCollapsed ? (
|
||||
<button className="w-10 h-10 mx-auto bg-primary rounded-xl flex items-center justify-center text-white shadow-soft hover:bg-primary-hover transition-colors">
|
||||
<Lightbulb className="w-5 h-5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-indigo-50/50 rounded-2xl p-4 border border-indigo-100/50 text-center relative overflow-hidden group">
|
||||
<div className="absolute -top-4 -right-4 w-12 h-12 bg-primary/10 rounded-full opacity-50 blur-xl group-hover:scale-150 transition-transform duration-700"></div>
|
||||
<div className="w-8 h-8 mx-auto bg-white rounded-full flex items-center justify-center shadow-sm mb-3">
|
||||
<span className="text-lg"><Lightbulb className="w-4.5 h-4.5 text-primary" /></span>
|
||||
</div>
|
||||
<h4 className="font-bold text-xs text-slate-800 mb-1.5 whitespace-nowrap">Need Assistance?</h4>
|
||||
<p className="text-[10px] text-slate-500 leading-relaxed mb-4">현장 장비 부족이나 기술적 이슈는 즉시 보고하세요.</p>
|
||||
<button onClick={() => router.push('/issues')} className="w-full py-2 bg-white rounded-xl text-[10px] font-bold text-primary shadow-sm hover:shadow-md transition-all border border-indigo-100">
|
||||
HQ에 긴급 메시지
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { UserCircle, Settings, LogOut, ChevronDown } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
export default function UserDropdown() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
if (open) document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
ADMIN: '최고관리자',
|
||||
MANAGER: '본사관리자',
|
||||
EMPLOYEE: '파견직원',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-3 pl-2 pr-1.5 py-1 rounded-xl hover:bg-gray-50 transition-all"
|
||||
>
|
||||
<div className="text-right hidden md:block">
|
||||
<p className="text-sm font-medium text-text-main leading-tight">{user?.name || 'User'}</p>
|
||||
<p className="text-xs text-text-muted">{roleLabel[user?.roleCode || ''] || user?.roleCode || 'HQ Center'}</p>
|
||||
</div>
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-primary to-purple-400 flex items-center justify-center text-white text-sm font-semibold shadow-sm flex-shrink-0">
|
||||
{user?.name?.[0] || 'U'}
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-text-muted transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-12 w-56 bg-white rounded-xl shadow-lg border border-border overflow-hidden z-50">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<p className="text-sm font-semibold text-text-main">{user?.name || 'User'}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{user?.email || ''}</p>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<Link href="/profile" onClick={() => setOpen(false)} className="flex items-center gap-3 px-4 py-2.5 text-sm text-text-main hover:bg-gray-50 transition-colors">
|
||||
<UserCircle className="w-4 h-4 text-text-muted" />
|
||||
프로필 정보
|
||||
</Link>
|
||||
|
||||
{user?.roleCode === 'ADMIN' && (
|
||||
<Link href="/settings/codes" onClick={() => setOpen(false)} className="flex items-center gap-3 px-4 py-2.5 text-sm text-text-main hover:bg-gray-50 transition-colors">
|
||||
<Settings className="w-4 h-4 text-text-muted" />
|
||||
설정 (공통코드)
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border py-1">
|
||||
<button onClick={() => { logout(); setOpen(false); window.location.href = '/login'; }} className="flex items-center gap-3 px-4 py-2.5 text-sm text-status-critical-text hover:bg-red-50 transition-colors w-full text-left">
|
||||
<LogOut className="w-4 h-4" />
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Search } from 'lucide-react';
|
||||
import projectService from '@/services/projectService';
|
||||
import employeeService from '@/services/employeeService';
|
||||
import type { Project, EmployeeSearchResult } from '@/types';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
editData?: Project | null;
|
||||
}
|
||||
|
||||
export default function ProjectFormModal({ isOpen, onClose, onSaved, editData }: Props) {
|
||||
const isEdit = !!editData;
|
||||
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectType, setProjectType] = useState<string>('SI');
|
||||
const [contractCompany, setContractCompany] = useState('');
|
||||
const [endUserCompany, setEndUserCompany] = useState('');
|
||||
const [contractType, setContractType] = useState<string>('TURN_KEY');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [workLocationType, setWorkLocationType] = useState<string>('ON_SITE');
|
||||
const [workAddress, setWorkAddress] = useState('');
|
||||
const [techEnvironment, setTechEnvironment] = useState('');
|
||||
const [mainTaskDesc, setMainTaskDesc] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Internal manager search
|
||||
const [mgrSearch, setMgrSearch] = useState('');
|
||||
const [mgrResults, setMgrResults] = useState<EmployeeSearchResult[]>([]);
|
||||
const [selectedMgr, setSelectedMgr] = useState<EmployeeSearchResult | null>(null);
|
||||
const [showMgrDropdown, setShowMgrDropdown] = useState(false);
|
||||
const debounceRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Pre-populate on edit
|
||||
useEffect(() => {
|
||||
if (!editData) {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
setProjectName(editData.projectName || '');
|
||||
setProjectType(editData.projectType || 'SI');
|
||||
setContractCompany(editData.contractCompany || '');
|
||||
setEndUserCompany(editData.endUserCompany || '');
|
||||
setContractType(editData.contractType || 'TURN_KEY');
|
||||
setStartDate(editData.startDate || '');
|
||||
setEndDate(editData.endDate || '');
|
||||
setWorkLocationType(editData.workLocationType || 'ON_SITE');
|
||||
setWorkAddress(editData.workAddress || '');
|
||||
setTechEnvironment(editData.techEnvironment || '');
|
||||
setMainTaskDesc(editData.mainTaskDesc || '');
|
||||
if (editData.internalManager) {
|
||||
setSelectedMgr(editData.internalManager as EmployeeSearchResult);
|
||||
setMgrSearch(editData.internalManager.name);
|
||||
}
|
||||
}, [editData, isOpen]);
|
||||
|
||||
const resetForm = () => {
|
||||
setProjectName('');
|
||||
setProjectType('SI');
|
||||
setContractCompany('');
|
||||
setEndUserCompany('');
|
||||
setContractType('TURN_KEY');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setWorkLocationType('ON_SITE');
|
||||
setWorkAddress('');
|
||||
setTechEnvironment('');
|
||||
setMainTaskDesc('');
|
||||
setMgrSearch('');
|
||||
setSelectedMgr(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleMgrSearch = (val: string) => {
|
||||
setMgrSearch(val);
|
||||
setShowMgrDropdown(true);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
if (val.trim().length >= 1) {
|
||||
employeeService.search(val.trim()).then((res) => {
|
||||
setMgrResults(res.data || []);
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
setMgrResults([]);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!projectName.trim() || !contractCompany.trim() || !startDate) {
|
||||
setError('Project name, contract company, and start date are required.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
projectName: projectName.trim(),
|
||||
projectType,
|
||||
contractCompany: contractCompany.trim(),
|
||||
endUserCompany: endUserCompany.trim() || undefined,
|
||||
contractType,
|
||||
startDate,
|
||||
endDate: endDate || undefined,
|
||||
workLocationType,
|
||||
workAddress: workAddress.trim() || undefined,
|
||||
techEnvironment: techEnvironment.trim() || undefined,
|
||||
mainTaskDesc: mainTaskDesc.trim() || undefined,
|
||||
internalManagerId: selectedMgr?.empId || null,
|
||||
};
|
||||
if (isEdit) {
|
||||
await projectService.update(editData!.projectId, payload);
|
||||
} else {
|
||||
await projectService.create(payload);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
resetForm();
|
||||
} catch {
|
||||
setError('Failed to save project. Please try again.');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h3 className="font-outfit font-semibold text-lg text-text-main">
|
||||
{isEdit ? 'Edit Project' : 'New Project'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<X className="w-5 h-5 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">
|
||||
Project Name <span className="text-status-critical-text">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="e.g. Samsung Pay SI"
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Project Type</label>
|
||||
<select
|
||||
value={projectType}
|
||||
onChange={(e) => setProjectType(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="SI">SI</option>
|
||||
<option value="SM">SM</option>
|
||||
<option value="MIGRATION">MIGRATION</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Contract Type</label>
|
||||
<select
|
||||
value={contractType}
|
||||
onChange={(e) => setContractType(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="TURN_KEY">TURN_KEY</option>
|
||||
<option value="MM">MM</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">
|
||||
Contract Company <span className="text-status-critical-text">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contractCompany}
|
||||
onChange={(e) => setContractCompany(e.target.value)}
|
||||
placeholder="e.g. Samsung Electronics"
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">End User Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={endUserCompany}
|
||||
onChange={(e) => setEndUserCompany(e.target.value)}
|
||||
placeholder="e.g. Samsung SDS"
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">
|
||||
Start Date <span className="text-status-critical-text">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Work Location Type</label>
|
||||
<select
|
||||
value={workLocationType}
|
||||
onChange={(e) => setWorkLocationType(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="ON_SITE">ON_SITE</option>
|
||||
<option value="REMOTE">REMOTE</option>
|
||||
<option value="HYBRID">HYBRID</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Work Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={workAddress}
|
||||
onChange={(e) => setWorkAddress(e.target.value)}
|
||||
placeholder="e.g. Seoul, Gangnam-gu"
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Tech Environment</label>
|
||||
<input
|
||||
type="text"
|
||||
value={techEnvironment}
|
||||
onChange={(e) => setTechEnvironment(e.target.value)}
|
||||
placeholder="e.g. Java, Spring Boot, React, PostgreSQL"
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Main Task Description</label>
|
||||
<textarea
|
||||
value={mainTaskDesc}
|
||||
onChange={(e) => setMainTaskDesc(e.target.value)}
|
||||
placeholder="Describe the main tasks and goals of the project..."
|
||||
className="w-full px-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Internal Manager Search */}
|
||||
<div className="relative">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5">Internal Manager</label>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={mgrSearch}
|
||||
onChange={(e) => handleMgrSearch(e.target.value)}
|
||||
onFocus={() => setShowMgrDropdown(true)}
|
||||
placeholder="Search manager by name..."
|
||||
className="w-full pl-9 pr-3 py-2 rounded-xl border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
{showMgrDropdown && mgrResults.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-border rounded-xl shadow-lg max-h-48 overflow-y-auto">
|
||||
{mgrResults.map((emp) => (
|
||||
<button
|
||||
key={emp.empId}
|
||||
type="button"
|
||||
onClick={() => { setSelectedMgr(emp); setMgrSearch(emp.name); setShowMgrDropdown(false); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{emp.name} <span className="text-text-muted text-xs">({emp.email})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedMgr && !showMgrDropdown && (
|
||||
<p className="text-xs text-primary mt-1">Selected: {selectedMgr.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-xl px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !projectName.trim() || !contractCompany.trim() || !startDate}
|
||||
className="px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { BookOpen, CheckCircle2, Clock, PlayCircle, BarChart3, RefreshCw } from 'lucide-react';
|
||||
import trainingService from '@/services/trainingService';
|
||||
import type { TrainingProgress, TrainingSummary } from '@/types';
|
||||
|
||||
interface TrainingCardProps {
|
||||
courseName: string;
|
||||
progressRate: number;
|
||||
status: string;
|
||||
progressId?: number;
|
||||
onUpdate?: (progressId: number, data: { progressRate?: number; status?: string }) => void;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
NOT_STARTED: { label: '미수강', icon: Clock, color: 'text-text-muted', bg: 'bg-gray-100' },
|
||||
ONGOING: { label: '진행중', icon: PlayCircle, color: 'text-status-pending-text', bg: 'bg-status-pending-bg' },
|
||||
COMPLETED: { label: '완료', icon: CheckCircle2, color: 'text-status-resolved-text', bg: 'bg-status-resolved-bg' },
|
||||
};
|
||||
|
||||
function TrainingCard({ courseName, progressRate, status, progressId, onUpdate }: TrainingCardProps) {
|
||||
const cfg = statusConfig[status as keyof typeof statusConfig] || statusConfig.NOT_STARTED;
|
||||
const StatusIcon = cfg.icon;
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
if (!onUpdate || !progressId) return;
|
||||
|
||||
let nextStatus: string;
|
||||
let nextProgress: number;
|
||||
|
||||
if (status === 'NOT_STARTED') {
|
||||
nextStatus = 'ONGOING';
|
||||
nextProgress = 10;
|
||||
} else if (status === 'ONGOING') {
|
||||
nextProgress = Math.min(progressRate + 25, 100);
|
||||
nextStatus = nextProgress === 100 ? 'COMPLETED' : 'ONGOING';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate(progressId, { progressRate: nextProgress, status: nextStatus });
|
||||
}, [onUpdate, progressId, status, progressRate]);
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-2xl border border-border p-5 hover:shadow-soft transition-all">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center text-primary">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${cfg.color} ${cfg.bg}`}>
|
||||
<StatusIcon className="w-3 h-3 mr-1" />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm text-text-main mb-3 line-clamp-2 min-h-[2.5rem]">{courseName}</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>진도율</span>
|
||||
<span className="font-medium text-text-main">{progressRate}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${progressRate}%`,
|
||||
backgroundColor: progressRate === 100 ? '#10B981' : progressRate > 50 ? '#4F46E5' : '#F59E0B',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{status !== 'COMPLETED' && onUpdate && (
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
className="w-full mt-3 text-xs text-primary hover:text-primary-container font-medium flex items-center justify-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
진도 업데이트
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryStats {
|
||||
total: number;
|
||||
completed: number;
|
||||
ongoing: number;
|
||||
notStarted: number;
|
||||
avgProgress: number;
|
||||
}
|
||||
|
||||
function calculateSummary(progresses: TrainingProgress[]): SummaryStats {
|
||||
const total = progresses.length;
|
||||
const completed = progresses.filter((p) => p.status === 'COMPLETED').length;
|
||||
const ongoing = progresses.filter((p) => p.status === 'ONGOING').length;
|
||||
const notStarted = progresses.filter((p) => p.status === 'NOT_STARTED').length;
|
||||
const avgProgress = total > 0 ? Math.round(progresses.reduce((sum, p) => sum + p.progressRate, 0) / total) : 0;
|
||||
|
||||
return { total, completed, ongoing, notStarted, avgProgress };
|
||||
}
|
||||
|
||||
export default function TrainingDashboard() {
|
||||
const [progresses, setProgresses] = useState<TrainingProgress[]>([]);
|
||||
const [summaries, setSummaries] = useState<TrainingSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [progressRes, summaryRes] = await Promise.all([
|
||||
trainingService.getMyProgress(),
|
||||
trainingService.getSummary(),
|
||||
]);
|
||||
|
||||
if (progressRes.success && progressRes.data) {
|
||||
setProgresses(progressRes.data);
|
||||
}
|
||||
if (summaryRes.success && summaryRes.data) {
|
||||
setSummaries(summaryRes.data);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err && typeof err === 'object' && 'message' in err ? (err as { message: string }).message : '데이터를 불러오지 못했습니다';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleUpdateProgress = useCallback(
|
||||
async (progressId: number, data: { progressRate?: number; status?: string }) => {
|
||||
try {
|
||||
const res = await trainingService.update(progressId, data);
|
||||
if (res.success && res.data) {
|
||||
setProgresses((prev) => prev.map((p) => (p.progressId === progressId ? res.data! : p)));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err && typeof err === 'object' && 'message' in err ? (err as { message: string }).message : '진도 업데이트에 실패했습니다';
|
||||
setError(message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const summary = calculateSummary(progresses);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-3xl font-semibold text-text-main tracking-tight">Training</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Track your internal training progress and course completion.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 text-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-text-muted text-sm">교육 데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-3xl font-semibold text-text-main tracking-tight">Training</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Track your internal training progress and course completion.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mx-auto mb-4">
|
||||
<RefreshCw className="w-6 h-6 text-error" />
|
||||
</div>
|
||||
<p className="text-error font-medium mb-2">{error}</p>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="text-primary hover:text-primary-container text-sm font-medium"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-3xl font-semibold text-text-main tracking-tight">Training</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Track your internal training progress and course completion.</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-text-muted">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>
|
||||
평균 진도율 <strong className="text-text-main">{summary.avgProgress}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">전체 과정</p>
|
||||
<p className="font-outfit text-2xl font-semibold text-text-main">{summary.total}</p>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">완료</p>
|
||||
<p className="font-outfit text-2xl font-semibold text-status-resolved-text">{summary.completed}</p>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">진행중</p>
|
||||
<p className="font-outfit text-2xl font-semibold text-status-pending-text">{summary.ongoing}</p>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">미수강</p>
|
||||
<p className="font-outfit text-2xl font-semibold text-text-muted">{summary.notStarted}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summaries.length > 0 && (
|
||||
<div className="bg-surface rounded-2xl border border-border p-5 mb-8">
|
||||
<h3 className="font-outfit text-lg font-semibold text-text-main mb-4">교육 과정별 통계</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 px-4 font-medium text-text-muted uppercase tracking-wider">Course Name</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-text-muted uppercase tracking-wider">Total Enrolled</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-text-muted uppercase tracking-wider">Completed</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-text-muted uppercase tracking-wider">Avg Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summaries.map((s, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0 hover:bg-surface-container-low">
|
||||
<td className="py-3 px-4 text-text-main font-medium">{s.courseName}</td>
|
||||
<td className="py-3 px-4 text-center text-text-muted">{s.totalEnrolled}</td>
|
||||
<td className="py-3 px-4 text-center text-status-resolved-text">{s.completedCount}</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary-fixed text-on-primary-fixed">
|
||||
{s.avgProgress}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progresses.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<BookOpen className="w-12 h-12 text-text-muted mx-auto mb-4 opacity-50" />
|
||||
<p className="text-text-muted text-sm">등록된 교육 과정이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{progresses.map((course) => (
|
||||
<TrainingCard key={course.progressId} {...course} onUpdate={handleUpdateProgress} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: 'open' | 'in-progress' | 'resolved' | 'closed' | 'critical';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
open: 'bg-[#EFF6FF] text-[#3B82F6]',
|
||||
'in-progress': 'bg-[#FFFBEB] text-[#F59E0B]',
|
||||
resolved: 'bg-[#ECFDF5] text-[#10B981]',
|
||||
closed: 'bg-[#F9FAFB] text-[#6B7280]',
|
||||
critical: 'bg-[#FEF2F2] text-[#EF4444] font-semibold',
|
||||
};
|
||||
|
||||
export default function Badge({
|
||||
variant = 'closed',
|
||||
children,
|
||||
className = '',
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${variantStyles[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-xl transition-all disabled:opacity-60 disabled:cursor-not-allowed';
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-[#4F46E5] hover:bg-[#4338CA] text-white',
|
||||
secondary: 'bg-[#8a4cfc] hover:bg-[#712AE2] text-white',
|
||||
outline: 'border border-[#4F46E5] text-[#4F46E5] hover:bg-[#4F46E5]/10',
|
||||
ghost: 'text-text-main hover:bg-[#F3F4F6]',
|
||||
danger: 'bg-[#EF4444] hover:bg-[#DC2626] text-white',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-5 py-2.5 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<span className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin mr-2" />
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export default function Card({
|
||||
children,
|
||||
className = '',
|
||||
hover = false,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-2xl border border-[#E5E7EB] p-6 ${
|
||||
hover ? 'hover:shadow-float transition-shadow' : 'shadow-card'
|
||||
} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Input({
|
||||
label,
|
||||
error,
|
||||
icon,
|
||||
className = '',
|
||||
...props
|
||||
}: InputProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className={`w-full px-4 py-2.5 rounded-xl border bg-white text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-[#4F46E5] focus:border-transparent transition-all ${
|
||||
icon ? 'pl-10' : ''
|
||||
} ${
|
||||
error
|
||||
? 'border-[#EF4444] focus:ring-[#EF4444]'
|
||||
: 'border-[#E5E7EB]'
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-[#EF4444]">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as Button } from './Button';
|
||||
export { default as Input } from './Input';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as Card } from './Card';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import commonCodeService from '@/services/commonCodeService';
|
||||
|
||||
interface CodeItem {
|
||||
codeValue: string;
|
||||
codeName: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function useCommonCodes(groupCode: string): { codes: CodeItem[]; loading: boolean } {
|
||||
const [codes, setCodes] = useState<CodeItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
commonCodeService.getByGroup(groupCode)
|
||||
.then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setCodes(res.data.map((c: any) => ({
|
||||
codeValue: c.codeValue,
|
||||
codeName: c.codeName,
|
||||
sortOrder: c.sortOrder,
|
||||
})));
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [groupCode]);
|
||||
|
||||
return { codes, loading };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function getCookie(name: string): string | null {
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
export function removeCookie(name: string) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export function middleware() {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/api/:path*'],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export const assignments = [
|
||||
{ assignmentId: 1, equipmentId: 1, userId: 3, userName: '김현장', assignedDate: '2026-02-01', returnedDate: null, condOnAssign: '미개봉 신품', condOnReturn: null },
|
||||
{ assignmentId: 2, equipmentId: 3, userId: 4, userName: '박파견', assignedDate: '2026-02-15', returnedDate: null, condOnAssign: '신품', condOnReturn: null },
|
||||
{ assignmentId: 3, equipmentId: 6, userId: 5, userName: '이전문', assignedDate: '2026-01-15', returnedDate: null, condOnAssign: '미개봉 신품', condOnReturn: null },
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
export const comments = [
|
||||
{ commentId: 1, ticketId: 1, userId: 3, userName: '김현장', commentText: '오라클 실행 계획 첨부합니다. Full Scan 발생 구간 확인 부탁드립니다.', attachedFileUrl: '/mock/exec_plan.png', createdAt: '2026-05-20T09:30:00Z' },
|
||||
{ commentId: 2, ticketId: 1, userId: 5, userName: '이전문 (본사)', commentText: '인덱스 힌트 사용해보세요: /*+ INDEX(emp IDX_EMP_DEPT) */\n통계 정보 갱신도 필요해 보입니다.', attachedFileUrl: null, createdAt: '2026-05-20T10:15:00Z' },
|
||||
{ commentId: 3, ticketId: 1, userId: 3, userName: '김현장', commentText: '힌트 적용 후 3초→0.5초로 개선되었습니다! ✅ 감사합니다.', attachedFileUrl: null, createdAt: '2026-05-20T11:00:00Z' },
|
||||
{ commentId: 4, ticketId: 3, userId: 6, userName: '최개발', commentText: '빌드 로그 첨부합니다. Dockerfile 45번째 라인 에러.', attachedFileUrl: '/mock/build_log.txt', createdAt: '2026-05-18T11:30:00Z' },
|
||||
{ commentId: 5, ticketId: 3, userId: 5, userName: '이전문 (본사)', commentText: 'Node 버전 mismatch 문제로 확인됩니다. .nvmrc 파일 확인해보세요.', attachedFileUrl: null, createdAt: '2026-05-18T14:00:00Z' },
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
export const commonCodes = [
|
||||
{ codeId: 1, groupCode: 'EMP_ROLE', codeValue: 'ADMIN', codeName: '최고관리자', sortOrder: 1, isActive: 'Y' },
|
||||
{ codeId: 2, groupCode: 'EMP_ROLE', codeValue: 'MANAGER', codeName: '본사관리자', sortOrder: 2, isActive: 'Y' },
|
||||
{ codeId: 3, groupCode: 'EMP_ROLE', codeValue: 'EMPLOYEE', codeName: '파견직원', sortOrder: 3, isActive: 'Y' },
|
||||
{ codeId: 4, groupCode: 'EMP_STATUS', codeValue: 'ACTIVE', codeName: '재직', sortOrder: 1, isActive: 'Y' },
|
||||
{ codeId: 5, groupCode: 'EMP_STATUS', codeValue: 'ON_LEAVE', codeName: '휴직', sortOrder: 2, isActive: 'Y' },
|
||||
{ codeId: 6, groupCode: 'EMP_STATUS', codeValue: 'RESIGNED', codeName: '퇴사', sortOrder: 3, isActive: 'Y' },
|
||||
{ codeId: 7, groupCode: 'TICKET_CATEGORY', codeValue: 'BUG', codeName: '버그', sortOrder: 1, isActive: 'Y' },
|
||||
{ codeId: 8, groupCode: 'TICKET_CATEGORY', codeValue: 'FEATURE', codeName: '기능', sortOrder: 2, isActive: 'Y' },
|
||||
{ codeId: 9, groupCode: 'TICKET_CATEGORY', codeValue: 'SUPPORT', codeName: '지원', sortOrder: 3, isActive: 'Y' },
|
||||
{ codeId: 10, groupCode: 'TICKET_CATEGORY', codeValue: 'QA', codeName: '품질', sortOrder: 4, isActive: 'Y' },
|
||||
{ codeId: 11, groupCode: 'EQUIP_TYPE', codeValue: 'LAPTOP', codeName: '노트북', sortOrder: 1, isActive: 'Y' },
|
||||
{ codeId: 12, groupCode: 'EQUIP_TYPE', codeValue: 'MONITOR', codeName: '모니터', sortOrder: 2, isActive: 'Y' },
|
||||
{ codeId: 13, groupCode: 'EQUIP_TYPE', codeValue: 'TEST_DEVICE', codeName: '테스트장비', sortOrder: 3, isActive: 'Y' },
|
||||
{ codeId: 14, groupCode: 'EQUIP_TYPE', codeValue: 'ACCESSORY', codeName: '액세서리', sortOrder: 4, isActive: 'Y' },
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
export const dispatchHistories = [
|
||||
{ dispatchId: 1, empId: 3, projectId: 1, startDate: '2025-01-01', endDate: null, roleTitle: 'BACKEND', contributionRate: 100, individualTech: 'Java 17, Spring Boot, Oracle', performedTasks: '차세대 결제 시스템 API 개발, 성능 튜닝', achievements: '결제 처리 시간 40% 개선', clientFeedback: null, internalRating: null },
|
||||
{ dispatchId: 2, empId: 4, projectId: 2, startDate: '2024-06-01', endDate: null, roleTitle: 'DBA', contributionRate: 100, individualTech: 'Oracle 11g, SQL Tuning, DataGuard', performedTasks: '운영 DB 모니터링, 장애 대응, 쿼리 튜닝', achievements: '월간 장애 건수 60% 감소', clientFeedback: null, internalRating: null },
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
export const employees = [
|
||||
{ empId: 1, loginId: 'admin', name: '김관리', email: 'admin@hq.com', roleCode: 'ADMIN' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'WAITING' as const, joinedDate: '2020-01-01', leftDate: null },
|
||||
{ empId: 2, loginId: 'manager1', name: '이매니저', email: 'manager@hq.com', roleCode: 'MANAGER' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'WAITING' as const, joinedDate: '2021-03-15', leftDate: null },
|
||||
{ empId: 3, loginId: 'kim', name: '김현장', email: 'kim@client.com', roleCode: 'EMPLOYEE' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'DISPATCHED' as const, joinedDate: '2022-06-01', leftDate: null },
|
||||
{ empId: 4, loginId: 'park', name: '박파견', email: 'park@client.com', roleCode: 'EMPLOYEE' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'DISPATCHED' as const, joinedDate: '2023-02-10', leftDate: null },
|
||||
{ empId: 5, loginId: 'lee', name: '이전문', email: 'lee@hq.com', roleCode: 'MANAGER' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'WAITING' as const, joinedDate: '2019-11-20', leftDate: null },
|
||||
{ empId: 6, loginId: 'choi', name: '최개발', email: 'choi@client.com', roleCode: 'EMPLOYEE' as const, empStatus: 'ACTIVE' as const, dispatchStatus: 'DISPATCHED' as const, joinedDate: '2023-08-05', leftDate: null },
|
||||
{ empId: 7, loginId: 'jung', name: '정휴직', email: 'jung@hq.com', roleCode: 'EMPLOYEE' as const, empStatus: 'ON_LEAVE' as const, dispatchStatus: 'WAITING' as const, joinedDate: '2021-07-01', leftDate: null },
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
export const equipment = [
|
||||
{ equipmentId: 1, equipmentType: 'LAPTOP' as const, manufacturer: 'Apple', modelName: 'MacBook Pro 16 M3', serialNumber: 'F2G8H3J9K1', assetCode: 'HQ-LP-2026-0001', specifications: { cpu: 'M3 Pro', ram: '36GB', ssd: '512GB' }, status: 'IN_USE' as const, purchaseDate: '2026-01-15', warrantyExpDate: '2028-01-15', version: 0 },
|
||||
{ equipmentId: 2, equipmentType: 'LAPTOP' as const, manufacturer: 'Apple', modelName: 'MacBook Pro 14 M3', serialNumber: 'A1B2C3D4E5', assetCode: 'HQ-LP-2026-0002', specifications: { cpu: 'M3', ram: '18GB', ssd: '512GB' }, status: 'AVAILABLE' as const, purchaseDate: '2026-02-01', warrantyExpDate: '2028-02-01', version: 0 },
|
||||
{ equipmentId: 3, equipmentType: 'MONITOR' as const, manufacturer: 'LG', modelName: '27UN880 Ergo', serialNumber: 'LG-27U-001', assetCode: 'HQ-MO-2026-0001', specifications: { size: '27"', resolution: '4K UHD', panel: 'IPS' }, status: 'IN_USE' as const, purchaseDate: '2026-01-20', warrantyExpDate: '2028-01-20', version: 0 },
|
||||
{ equipmentId: 4, equipmentType: 'LAPTOP' as const, manufacturer: 'Samsung', modelName: 'Galaxy Book4 Pro', serialNumber: 'SAMSUNG-001', assetCode: 'HQ-LP-2026-0003', specifications: { cpu: 'Ultra 7', ram: '16GB', ssd: '256GB' }, status: 'REPAIRING' as const, purchaseDate: '2026-03-01', warrantyExpDate: '2028-03-01', version: 0 },
|
||||
{ equipmentId: 5, equipmentType: 'MONITOR' as const, manufacturer: 'Dell', modelName: 'U2723QE', serialNumber: 'DELL-U27-002', assetCode: 'HQ-MO-2026-0002', specifications: { size: '27"', resolution: '4K', panel: 'IPS Black' }, status: 'AVAILABLE' as const, purchaseDate: '2026-04-01', warrantyExpDate: '2028-04-01', version: 0 },
|
||||
{ equipmentId: 6, equipmentType: 'ACCESSORY' as const, manufacturer: 'Logitech', modelName: 'MX Master 3S', serialNumber: 'LOG-MX-001', assetCode: 'HQ-AC-2026-0001', specifications: { type: 'Mouse', connection: 'Bluetooth' }, status: 'IN_USE' as const, purchaseDate: '2026-01-10', warrantyExpDate: '2027-01-10', version: 0 },
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export const maintenances = [
|
||||
{ maintenanceId: 1, equipmentId: 4, reportedBy: 3, maintenanceType: 'REPAIR' as const, issueDescription: '키보드 침수 작동 불가', actionTaken: '공식 서비스센터 키보드 상판 교체 완료', cost: 250000, reportedDate: '2026-05-10', resolvedDate: '2026-05-15' },
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
export const notifications = [
|
||||
{ notificationId: 1, type: 'TICKET', title: 'SR 티켓 배정됨', message: 'SR-2026-003 CI/CD 배포 실패 건이 이전문 매니저에게 배정되었습니다.', isRead: false, createdAt: '2026-05-21T09:00:00Z' },
|
||||
{ notificationId: 2, type: 'REPORT', title: '보고서 반려', message: '주간 보고서(WR-2026-05-W1)가 반려되었습니다. 피드백을 확인해주세요.', isRead: false, createdAt: '2026-05-20T14:00:00Z' },
|
||||
{ notificationId: 3, type: 'TICKET', title: 'SLA 임박 ⚠️', message: 'SR-2026-001 JVM 메모리 릭 건 SLA 데드라인이 4시간 남았습니다.', isRead: false, createdAt: '2026-05-21T07:00:00Z' },
|
||||
{ notificationId: 4, type: 'EQUIPMENT', title: '장비 수리 완료', message: 'Galaxy Book4 Pro 키보드 수리가 완료되어 창고 입고되었습니다.', isRead: true, createdAt: '2026-05-19T16:00:00Z' },
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
export const projects = [
|
||||
{ projectId: 1, projectType: 'SI' as const, projectName: '삼성페이 고도화 (SI)', contractCompany: '삼성전자', endUserCompany: '삼성전자', contractType: 'TURN_KEY' as const, status: 'ONGOING' as const, startDate: '2025-01-01', endDate: '2026-12-31', workLocationType: 'ON_SITE' as const, workAddress: '서울시 서초구', techEnvironment: 'Java 17, Spring Boot, Oracle 19c', mainTaskDesc: '삼성페이 차세대 시스템 구축', internalManagerId: 1 },
|
||||
{ projectId: 2, projectType: 'SM' as const, projectName: 'LG CNS SM 운영', contractCompany: 'LG CNS', endUserCompany: 'LG CNS', contractType: 'MM' as const, status: 'ONGOING' as const, startDate: '2024-01-01', endDate: '2026-06-30', workLocationType: 'HYBRID' as const, workAddress: '서울시 강남구', techEnvironment: 'Java 11, Spring, Oracle 11g', mainTaskDesc: 'LG CNS 시스템 유지보수', internalManagerId: 2 },
|
||||
{ projectId: 3, projectType: 'SM' as const, projectName: 'Kakao Maintenance', contractCompany: '카카오', endUserCompany: '카카오', contractType: 'MM' as const, status: 'ONGOING' as const, startDate: '2024-06-01', endDate: '2025-12-31', workLocationType: 'REMOTE' as const, workAddress: '경기도 판교', techEnvironment: 'Kotlin, Spring Cloud, MySQL', mainTaskDesc: '카카오 서비스 운영 유지보수', internalManagerId: 2 },
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
export const reports = [
|
||||
{ reportId: 1, dispatchId: 1, userId: 3, userName: '김현장', reportType: 'WEEKLY' as const, targetDate: '2026-05-15', completedTasks: 'SR 3건 해결 완료 (DB튜닝, 배포파이프라인, 모니터링)', plannedTasks: '차주 성능 테스트 진행, 인덱스 리뷰', issuesAndRisks: '납기 일정 협의 필요', supportRequested: null, status: 'APPROVED' as const, reviewerId: 2, reviewerName: '이매니저', reviewComment: '수고하셨습니다.', createdAt: '2026-05-15T10:00:00Z', updatedAt: '2026-05-16T09:00:00Z' },
|
||||
{ reportId: 2, dispatchId: 1, userId: 3, userName: '김현장', reportType: 'WEEKLY' as const, targetDate: '2026-05-22', completedTasks: '오라클 튜닝 2건, 장애 대응 1건', plannedTasks: 'DBA 정기 점검, 교육 수강', issuesAndRisks: null, supportRequested: null, status: 'SUBMITTED' as const, reviewerId: null, reviewerName: null, reviewComment: null, createdAt: '2026-05-22T10:00:00Z', updatedAt: '2026-05-22T10:00:00Z' },
|
||||
{ reportId: 3, dispatchId: 2, userId: 4, userName: '박파견', reportType: 'WEEKLY' as const, targetDate: '2026-05-08', completedTasks: 'DB 모니터링, 장애 대응 2건', plannedTasks: '인덱스 튜닝 작업 예정', issuesAndRisks: '장비 SSD 불량 교체 필요', supportRequested: '노트북 SSD 교체 요청', status: 'REJECTED' as const, reviewerId: 2, reviewerName: '이매니저', reviewComment: '완료 실적에 SR 연동 내용 상세 보강 요망', createdAt: '2026-05-08T09:00:00Z', updatedAt: '2026-05-09T14:00:00Z' },
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
export const tickets = [
|
||||
{ ticketId: 1, projectId: 1, requesterId: 3, requesterName: '김현장', assigneeId: null, assigneeName: null, category: 'BUG' as const, priority: 'CRITICAL' as const, title: 'JVM 메모리 릭 및 Heap Dump 분석 요청', description: 'Prod 서비스 JVM 메모리 사용량 지속 증가, Full GC 빈도 높음', status: 'OPEN' as const, dueDate: '2026-05-22', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-20T09:00:00Z', updatedAt: '2026-05-20T09:00:00Z' },
|
||||
{ ticketId: 2, projectId: 2, requesterId: 4, requesterName: '박파견', assigneeId: null, assigneeName: null, category: 'SUPPORT' as const, priority: 'HIGH' as const, title: '사내망 방화벽 정책 가이드 필요', description: '파견지 사내망 연동을 위한 방화벽 정책 가이드 요청', status: 'OPEN' as const, dueDate: '2026-05-25', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-19T14:00:00Z', updatedAt: '2026-05-19T14:00:00Z' },
|
||||
{ ticketId: 3, projectId: 1, requesterId: 6, requesterName: '최개발', assigneeId: 5, assigneeName: '이전문', category: 'BUG' as const, priority: 'CRITICAL' as const, title: 'CI/CD 파이프라인 staging 배포 실패', description: 'Staging 환경 배포 시 Docker build 실패', status: 'ASSIGNED' as const, dueDate: '2026-05-22', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-18T11:00:00Z', updatedAt: '2026-05-19T09:00:00Z' },
|
||||
{ ticketId: 4, projectId: 3, requesterId: 4, requesterName: '박파견', assigneeId: 2, assigneeName: '이매니저', category: 'SUPPORT' as const, priority: 'NORMAL' as const, title: 'MacBook Pro 배터리 교체 요청', description: '배터리 성능 78%로 교체 주기 도래', status: 'IN_PROGRESS' as const, dueDate: '2026-05-30', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-17T10:00:00Z', updatedAt: '2026-05-18T15:00:00Z' },
|
||||
{ ticketId: 5, projectId: 2, requesterId: 3, requesterName: '김현장', assigneeId: 5, assigneeName: '이전문', category: 'SUPPORT' as const, priority: 'HIGH' as const, title: 'VPN 접속 연장 요청', description: '원격 접속 VPN 인증서 만료 예정', status: 'RESOLVED' as const, dueDate: '2026-05-20', resolvedAt: '2026-05-19T17:00:00Z', resolutionNote: 'VPN 인증서 재발급 완료', slaWarnFlag: 'N' as const, createdAt: '2026-05-16T08:00:00Z', updatedAt: '2026-05-19T17:00:00Z' },
|
||||
{ ticketId: 6, projectId: 1, requesterId: 6, requesterName: '최개발', assigneeId: 5, assigneeName: '이전문', category: 'BUG' as const, priority: 'HIGH' as const, title: 'DB 커넥션 풀 고갈 문제 분석', description: '특정 시간대에 DB 커넥션 풀이 고갈되는 현상', status: 'CLOSED' as const, dueDate: '2026-05-18', resolvedAt: '2026-05-17T12:00:00Z', resolutionNote: 'Connection leak, 코드 수정 완료', slaWarnFlag: 'N' as const, createdAt: '2026-05-15T09:00:00Z', updatedAt: '2026-05-17T12:00:00Z' },
|
||||
{ ticketId: 7, projectId: 1, requesterId: 3, requesterName: '김현장', assigneeId: null, assigneeName: null, category: 'QA' as const, priority: 'HIGH' as const, title: '오라클 쿼리 튜닝 필요 (Full Scan)', description: '실행 계획 상 Full Scan 발생, 인덱스 튜닝 필요', status: 'OPEN' as const, dueDate: '2026-05-28', resolvedAt: null, resolutionNote: null, slaWarnFlag: 'N' as const, createdAt: '2026-05-21T08:00:00Z', updatedAt: '2026-05-21T08:00:00Z' },
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
export const training = [
|
||||
{ progressId: 1, userId: 3, courseName: 'Next.js Enterprise Architecture', progressRate: 75, status: 'ONGOING' as const, updatedAt: '2026-05-15T10:00:00Z' },
|
||||
{ progressId: 2, userId: 3, courseName: 'SpringBoot JPA Masterclass', progressRate: 100, status: 'COMPLETED' as const, updatedAt: '2026-04-20T10:00:00Z' },
|
||||
{ progressId: 3, userId: 3, courseName: 'Oracle SQL Tuning & Optimization', progressRate: 30, status: 'ONGOING' as const, updatedAt: '2026-05-18T10:00:00Z' },
|
||||
{ progressId: 4, userId: 3, courseName: 'Kubernetes for DevOps Engineers', progressRate: 0, status: 'NOT_STARTED' as const, updatedAt: '2026-05-01T10:00:00Z' },
|
||||
{ progressId: 5, userId: 3, courseName: 'React 19 Advanced Patterns', progressRate: 100, status: 'COMPLETED' as const, updatedAt: '2026-03-15T10:00:00Z' },
|
||||
{ progressId: 6, userId: 3, courseName: 'Cloud Architecture on AWS', progressRate: 0, status: 'NOT_STARTED' as const, updatedAt: '2026-05-01T10:00:00Z' },
|
||||
];
|
||||
@@ -0,0 +1,535 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import type { RoleCode } from '@/types';
|
||||
import { employees as _employees } from './data/employees';
|
||||
import { projects as _projects } from './data/projects';
|
||||
import { dispatchHistories as _dispatchHistories } from './data/dispatchHistories';
|
||||
import { tickets as _tickets } from './data/tickets';
|
||||
import { comments as _comments } from './data/comments';
|
||||
import { equipment as _equipment } from './data/equipment';
|
||||
import { assignments as _assignments } from './data/assignments';
|
||||
import { maintenances as _maintenances } from './data/maintenances';
|
||||
import { reports as _reports } from './data/reports';
|
||||
import { training as _training } from './data/training';
|
||||
import { notifications as _notifications } from './data/notifications';
|
||||
import { commonCodes as _commonCodes } from './data/commonCodes';
|
||||
|
||||
interface MockUser {
|
||||
loginId: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role: RoleCode;
|
||||
}
|
||||
|
||||
const MOCK_USERS: MockUser[] = [
|
||||
{ loginId: 'admin', password: 'admin123', name: 'Admin', role: 'ADMIN' },
|
||||
{ loginId: 'manager', password: 'manager123', name: 'Manager Kim', role: 'MANAGER' },
|
||||
{ loginId: 'employee', password: 'emp123', name: 'Employee Lee', role: 'EMPLOYEE' },
|
||||
];
|
||||
|
||||
function paginate<T>(arr: T[], page = 0, size = 20) {
|
||||
const start = page * size;
|
||||
const content = arr.slice(start, start + size);
|
||||
return { content, totalElements: arr.length, totalPages: Math.ceil(arr.length / size), number: page, size };
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function setupMocks(instance: AxiosInstance) {
|
||||
if (typeof window === 'undefined' || initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const mock = new MockAdapter(instance, { delayResponse: 200 });
|
||||
|
||||
const s: Record<string, any[]> = {
|
||||
employees: _employees,
|
||||
projects: _projects,
|
||||
dispatchHistories: _dispatchHistories,
|
||||
tickets: _tickets,
|
||||
comments: _comments,
|
||||
equipment: _equipment,
|
||||
assignments: _assignments,
|
||||
maintenances: _maintenances,
|
||||
reports: _reports,
|
||||
training: _training,
|
||||
notifications: _notifications,
|
||||
commonCodes: _commonCodes,
|
||||
};
|
||||
|
||||
/* Auth */
|
||||
mock.onPost('/auth/login').reply((config) => {
|
||||
const { loginId, password } = JSON.parse(config.data);
|
||||
const mockUser = MOCK_USERS.find((u) => u.loginId === loginId && u.password === password);
|
||||
if (!mockUser) {
|
||||
return [401, { success: false, message: '아이디 또는 비밀번호가 일치하지 않습니다.', errorCode: 'AUTH_001' }];
|
||||
}
|
||||
const emp = s.employees.find((e: any) => e.loginId === loginId) || s.employees[0];
|
||||
const accessToken = `mock-jwt-${mockUser.loginId}-${Date.now()}`;
|
||||
const refreshToken = `mock-refresh-${mockUser.loginId}-${Date.now()}`;
|
||||
return [200, { success: true, message: '로그인 성공', data: { accessToken, refreshToken, employee: emp } }];
|
||||
});
|
||||
|
||||
mock.onPost('/auth/refresh').reply((config) => {
|
||||
const { refreshToken } = JSON.parse(config.data);
|
||||
if (!refreshToken || !refreshToken.startsWith('mock-refresh-')) {
|
||||
return [401, { success: false, message: '유효하지 않은 리프레시 토큰입니다.', errorCode: 'AUTH_002' }];
|
||||
}
|
||||
const loginId = refreshToken.split('-')[2];
|
||||
const emp = s.employees.find((e: any) => e.loginId === loginId) || s.employees[0];
|
||||
const accessToken = `mock-jwt-${loginId}-${Date.now()}`;
|
||||
const newRefreshToken = `mock-refresh-${loginId}-${Date.now()}`;
|
||||
return [200, { success: true, message: '토큰 갱신 성공', data: { accessToken, refreshToken: newRefreshToken, employee: emp } }];
|
||||
});
|
||||
|
||||
mock.onGet('/employees/me').reply((config) => {
|
||||
const authHeader = config.headers?.Authorization;
|
||||
if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const loginId = token.split('-')[2];
|
||||
const emp = s.employees.find((e: any) => e.loginId === loginId);
|
||||
if (emp) return [200, { success: true, message: 'OK', data: emp }];
|
||||
}
|
||||
return [200, { success: true, message: 'OK', data: s.employees[0] }];
|
||||
});
|
||||
|
||||
mock.onGet('/employees').reply((config) => [200, { success: true, message: 'OK', data: paginate(s.employees, config.params?.page, config.params?.size) }]);
|
||||
|
||||
mock.onGet(/\/employees\/search/).reply((config) => {
|
||||
const keyword = (config.params?.keyword || '').toLowerCase();
|
||||
const filtered = s.employees.filter((e: any) => e.name.toLowerCase().includes(keyword) || e.email.toLowerCase().includes(keyword));
|
||||
return [200, { success: true, message: 'OK', data: filtered.map((e: any) => ({ empId: e.empId, name: e.name, roleCode: e.roleCode, dispatchStatus: e.dispatchStatus, email: e.email })) }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/employees\/\d+$/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/employees\/(\d+)$/)?.[1] || '0');
|
||||
const emp = s.employees.find((e: any) => e.empId === id);
|
||||
if (!emp) return [404, { success: false, message: 'Not found' }];
|
||||
return [200, { success: true, message: 'OK', data: emp }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/employees\/\d+\/dispatch-histories/).reply(() => [200, { success: true, message: 'OK', data: s.dispatchHistories }]);
|
||||
|
||||
/* Tickets */
|
||||
mock.onGet('/tickets').reply((config) => {
|
||||
let result = [...s.tickets];
|
||||
const { category, priority, status, searchKeyword } = config.params || {};
|
||||
if (category && category !== 'ALL') result = result.filter((t: any) => t.category === category);
|
||||
if (priority && priority !== 'ALL') result = result.filter((t: any) => t.priority === priority);
|
||||
if (status && status !== 'ALL') result = result.filter((t: any) => t.status === status);
|
||||
if (searchKeyword) result = result.filter((t: any) => t.title.toLowerCase().includes(searchKeyword.toLowerCase()));
|
||||
return [200, { success: true, message: 'OK', data: paginate(result, config.params?.page, config.params?.size) }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/tickets\/(\d+)$/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/tickets\/(\d+)$/)?.[1] || '0');
|
||||
const ticket = s.tickets.find((t: any) => t.ticketId === id);
|
||||
if (!ticket) return [404, { success: false, message: 'Not found' }];
|
||||
ticket.isImminent = ticket.dueDate ? new Date(ticket.dueDate).getTime() - Date.now() < 86400000 : false;
|
||||
return [200, { success: true, message: 'OK', data: ticket }];
|
||||
});
|
||||
|
||||
mock.onPost(/\/tickets\/(\d+)\/comments/).reply((config) => {
|
||||
const ticketId = parseInt(config.url?.match(/\/tickets\/(\d+)\/comments/)?.[1] || '0');
|
||||
const { commentText, attachedFileUrl } = JSON.parse(config.data);
|
||||
const newComment = { commentId: Date.now(), ticketId, userId: 1, userName: 'Admin User (본사)', commentText, attachedFileUrl: attachedFileUrl || null, createdAt: new Date().toISOString() };
|
||||
return [201, { success: true, message: '등록 완료', data: newComment }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/tickets\/(\d+)\/comments/).reply((config) => {
|
||||
const ticketId = parseInt(config.url?.match(/\/tickets\/(\d+)\/comments/)?.[1] || '0');
|
||||
return [200, { success: true, message: 'OK', data: s.comments.filter((c: any) => c.ticketId === ticketId) }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/tickets\/(\d+)\/status/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/tickets\/(\d+)\/status/)?.[1] || '0');
|
||||
const ticket = s.tickets.find((t: any) => t.ticketId === id);
|
||||
if (ticket) ticket.status = config.params?.status || ticket.status;
|
||||
return [200, { success: true, message: '상태 변경 완료', data: ticket }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/tickets\/(\d+)\/resolve/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/tickets\/(\d+)\/resolve/)?.[1] || '0');
|
||||
const ticket = s.tickets.find((t: any) => t.ticketId === id);
|
||||
if (ticket) { ticket.status = 'RESOLVED'; ticket.resolutionNote = JSON.parse(config.data).resolutionNote; ticket.resolvedAt = new Date().toISOString(); }
|
||||
return [200, { success: true, message: '해결 처리 완료', data: ticket }];
|
||||
});
|
||||
|
||||
mock.onDelete(/\/tickets\/(\d+)$/).reply(204);
|
||||
|
||||
/* Equipment */
|
||||
mock.onGet('/equipment').reply((config) => {
|
||||
let result = [...s.equipment];
|
||||
const { type, status, manufacturer } = config.params || {};
|
||||
if (type) result = result.filter((e: any) => e.equipmentType === type);
|
||||
if (status) result = result.filter((e: any) => e.status === status);
|
||||
if (manufacturer) result = result.filter((e: any) => e.manufacturer === manufacturer);
|
||||
return [200, { success: true, message: 'OK', data: paginate(result, config.params?.page, config.params?.size) }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/equipment\/(\d+)$/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/equipment\/(\d+)$/)?.[1] || '0');
|
||||
const equip = s.equipment.find((e: any) => e.equipmentId === id);
|
||||
if (!equip) return [404, { success: false, message: 'Not found' }];
|
||||
return [200, { success: true, message: 'OK', data: equip }];
|
||||
});
|
||||
|
||||
mock.onPost('/equipment').reply((config) => {
|
||||
const body = JSON.parse(config.data);
|
||||
const newEquip = { equipmentId: Date.now(), ...body, version: 0 };
|
||||
s.equipment.push(newEquip);
|
||||
return [201, { success: true, message: '등록 완료', data: newEquip }];
|
||||
});
|
||||
|
||||
mock.onPost(/\/equipment\/(\d+)\/assign/).reply((config) => {
|
||||
const equipId = parseInt(config.url?.match(/\/equipment\/(\d+)\/assign/)?.[1] || '0');
|
||||
const { assigneeId, conditionNote } = JSON.parse(config.data);
|
||||
const equip = s.equipment.find((e: any) => e.equipmentId === equipId);
|
||||
if (!equip || equip.status !== 'AVAILABLE') return [400, { success: false, message: '지급 가능한 재고 상태가 아닙니다.', errorCode: 'EQP_ERR_001' }];
|
||||
equip.status = 'IN_USE';
|
||||
const assignment = { assignmentId: Date.now(), equipmentId: equipId, userId: assigneeId, userName: s.employees.find((e: any) => e.empId === assigneeId)?.name || '', assignedDate: new Date().toISOString().split('T')[0], returnedDate: null, condOnAssign: conditionNote, condOnReturn: null };
|
||||
s.assignments.push(assignment);
|
||||
return [200, { success: true, message: '장비 지급 완료', data: assignment }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/equipment\/(\d+)\/return/).reply((config) => {
|
||||
const equipId = parseInt(config.url?.match(/\/equipment\/(\d+)\/return/)?.[1] || '0');
|
||||
const { conditionNote } = JSON.parse(config.data);
|
||||
const equip = s.equipment.find((e: any) => e.equipmentId === equipId);
|
||||
if (equip) equip.status = 'AVAILABLE';
|
||||
const active = s.assignments.find((a: any) => a.equipmentId === equipId && !a.returnedDate);
|
||||
if (active) { active.returnedDate = new Date().toISOString().split('T')[0]; active.condOnReturn = conditionNote; }
|
||||
return [200, { success: true, message: '반납 완료', data: active }];
|
||||
});
|
||||
|
||||
mock.onGet('/equipment/summary').reply(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
s.equipment.forEach((e: any) => { counts[e.status] = (counts[e.status] || 0) + 1; });
|
||||
counts.ALL = Object.values(counts).reduce((a: number, b: any) => a + (typeof b === 'number' ? b : 0), 0);
|
||||
return [200, { success: true, message: 'OK', data: { counts } }];
|
||||
});
|
||||
|
||||
/* Reports */
|
||||
mock.onGet('/reports').reply((config) => {
|
||||
let result = [...s.reports];
|
||||
const { reportType, status } = config.params || {};
|
||||
if (reportType) result = result.filter((r: any) => r.reportType === reportType);
|
||||
if (status) result = result.filter((r: any) => r.status === status);
|
||||
return [200, { success: true, message: 'OK', data: paginate(result, config.params?.page, config.params?.size) }];
|
||||
});
|
||||
|
||||
mock.onPost('/reports').reply((config) => {
|
||||
const body = JSON.parse(config.data);
|
||||
const newReport = { reportId: Date.now(), ...body, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
|
||||
s.reports.push(newReport);
|
||||
return [201, { success: true, message: '보고서 생성 완료', data: newReport }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/reports\/(\d+)\/review/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/reports\/(\d+)\/review/)?.[1] || '0');
|
||||
const { status, reviewComment } = JSON.parse(config.data);
|
||||
const report = s.reports.find((r: any) => r.reportId === id);
|
||||
if (report) { report.status = status; report.reviewComment = reviewComment || null; }
|
||||
return [200, { success: true, message: '결재 처리 완료', data: report }];
|
||||
});
|
||||
|
||||
mock.onGet('/reports/sr-auto-complete').reply(() => [200, { success: true, message: 'OK', data: ['SR-2026-042: 오라클 인덱스 튜닝 완료', 'SR-2026-043: 배포 파이프라인 오류 수정'] }]);
|
||||
|
||||
/* Training */
|
||||
mock.onGet('/training/me').reply(() => [200, { success: true, message: 'OK', data: s.training }]);
|
||||
mock.onGet('/training/summary').reply(() => {
|
||||
const byCourse: Record<string, any> = {};
|
||||
s.training.forEach((t: any) => {
|
||||
if (!byCourse[t.courseName]) byCourse[t.courseName] = { total: 0, completed: 0, totalRate: 0 };
|
||||
byCourse[t.courseName].total++;
|
||||
if (t.status === 'COMPLETED') byCourse[t.courseName].completed++;
|
||||
byCourse[t.courseName].totalRate += t.progressRate;
|
||||
});
|
||||
const summary = Object.entries(byCourse).map(([courseName, v]) => ({ courseName, totalEnrolled: v.total, completedCount: v.completed, avgProgress: Math.round(v.totalRate / v.total) }));
|
||||
return [200, { success: true, message: 'OK', data: summary }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/training\/(\d+)$/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/training\/(\d+)/)?.[1] || '0');
|
||||
const body = JSON.parse(config.data);
|
||||
const prog = s.training.find((t: any) => t.progressId === id);
|
||||
if (prog) { if (body.progressRate !== undefined) prog.progressRate = body.progressRate; if (body.status) prog.status = body.status; }
|
||||
return [200, { success: true, message: 'OK', data: prog }];
|
||||
});
|
||||
|
||||
/* Notifications */
|
||||
mock.onGet('/notifications/unread').reply(() => [200, { success: true, message: 'OK', data: { count: s.notifications.filter((n: any) => !n.isRead).length } }]);
|
||||
mock.onGet('/notifications').reply(() => [200, { success: true, message: 'OK', data: s.notifications }]);
|
||||
mock.onPut(/\/notifications\/(\d+)\/read$/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/notifications\/(\d+)\/read/)?.[1] || '0');
|
||||
const n = s.notifications.find((x: any) => x.notificationId === id);
|
||||
if (n) n.isRead = true;
|
||||
return [200, { success: true, message: 'OK', data: null }];
|
||||
});
|
||||
|
||||
/* Parking */
|
||||
mock.onGet('/health').reply(() => {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const hour = now.getHours();
|
||||
const parked = day === 0 || day === 6 || hour < 8 || hour >= 20;
|
||||
return [200, { success: true, message: 'OK', data: { parked } }];
|
||||
});
|
||||
|
||||
mock.onPost('/wake').reply(() => new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([200, { success: true, message: '관리자에게 기동 요청이 전송되었습니다. iLO4 원격 부팅이 진행됩니다.', data: { success: true, message: '관리자에게 기동 요청이 전송되었습니다. iLO4 원격 부팅이 진행됩니다.', estimatedWakeTime: '약 5~10분 소요' } }]);
|
||||
}, 3000);
|
||||
}));
|
||||
|
||||
/* Projects */
|
||||
mock.onGet('/projects').reply((config) => {
|
||||
let result = [...s.projects];
|
||||
const { status } = config.params || {};
|
||||
if (status) result = result.filter((p: any) => p.status === status);
|
||||
return [200, { success: true, message: 'OK', data: result }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/projects\/\d+/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/projects\/(\d+)/)?.[1] || '0');
|
||||
const project = s.projects.find((p: any) => p.projectId === id);
|
||||
if (!project) return [404, { success: false, message: 'Not found' }];
|
||||
return [200, { success: true, message: 'OK', data: project }];
|
||||
});
|
||||
|
||||
mock.onPost('/projects').reply((config) => {
|
||||
const body = JSON.parse(config.data);
|
||||
const newProject = { projectId: Date.now(), ...body, createdAt: new Date().toISOString() };
|
||||
s.projects.push(newProject);
|
||||
return [201, { success: true, message: '프로젝트가 등록되었습니다', data: newProject }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/projects\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/projects\/(\d+)/)?.[1] || '0');
|
||||
const body = JSON.parse(config.data);
|
||||
const project = s.projects.find((p: any) => p.projectId === id);
|
||||
if (!project) return [404, { success: false, message: 'Not found' }];
|
||||
Object.assign(project, body);
|
||||
return [200, { success: true, message: '프로젝트가 수정되었습니다', data: project }];
|
||||
});
|
||||
|
||||
mock.onDelete(/\/projects\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/projects\/(\d+)/)?.[1] || '0');
|
||||
const idx = s.projects.findIndex((p: any) => p.projectId === id);
|
||||
if (idx === -1) return [404, { success: false, message: 'Not found' }];
|
||||
s.projects.splice(idx, 1);
|
||||
return [200, { success: true, message: '프로젝트가 삭제되었습니다', data: null }];
|
||||
});
|
||||
|
||||
/* Common Codes */
|
||||
mock.onGet('/common-codes').reply((config) => {
|
||||
let result = [...s.commonCodes];
|
||||
const { groupCode } = config.params || {};
|
||||
if (groupCode) result = result.filter((c: any) => c.groupCode === groupCode);
|
||||
return [200, { success: true, message: 'OK', data: result }];
|
||||
});
|
||||
|
||||
mock.onGet('/common-codes/groups').reply(() => {
|
||||
const groups = [...new Set(s.commonCodes.map((c: any) => c.groupCode))];
|
||||
return [200, { success: true, message: 'OK', data: groups }];
|
||||
});
|
||||
|
||||
mock.onPost('/common-codes').reply((config) => {
|
||||
const body = JSON.parse(config.data);
|
||||
const newCode = { codeId: Date.now(), ...body };
|
||||
s.commonCodes.push(newCode);
|
||||
return [200, { success: true, message: '생성 완료', data: newCode }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/common-codes\/\d+/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/common-codes\/(\d+)/)?.[1] || '0');
|
||||
const body = JSON.parse(config.data);
|
||||
const idx = s.commonCodes.findIndex((c: any) => c.codeId === id);
|
||||
if (idx === -1) return [404, { success: false, message: 'Not found' }];
|
||||
s.commonCodes[idx] = { ...s.commonCodes[idx], ...body };
|
||||
return [200, { success: true, message: '수정 완료', data: s.commonCodes[idx] }];
|
||||
});
|
||||
|
||||
mock.onDelete(/\/common-codes\/\d+/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/common-codes\/(\d+)/)?.[1] || '0');
|
||||
const idx = s.commonCodes.findIndex((c: any) => c.codeId === id);
|
||||
if (idx === -1) return [404, { success: false, message: 'Not found' }];
|
||||
s.commonCodes.splice(idx, 1);
|
||||
return [200, { success: true, message: '삭제 완료', data: null }];
|
||||
});
|
||||
|
||||
/* Calendar Events */
|
||||
mock.onGet('/calendar/events').reply(() => {
|
||||
const events: any[] = [];
|
||||
const now = new Date();
|
||||
// Ticket due dates
|
||||
s.tickets.forEach((t: any) => {
|
||||
if (t.dueDate) events.push({ eventType: 'TICKET_DUE', sourceId: t.ticketId, title: t.title, eventDate: t.dueDate.split('T')[0], color: '#3B82F6', linkUrl: `/issues/${t.ticketId}` });
|
||||
});
|
||||
return [200, { success: true, message: 'OK', data: events }];
|
||||
});
|
||||
|
||||
/* Employee CRUD */
|
||||
mock.onPost('/employees').reply((config) => {
|
||||
const body = JSON.parse(config.data);
|
||||
const newEmp = { empId: Date.now(), ...body, password: 'encoded', createdAt: new Date().toISOString() };
|
||||
s.employees.push(newEmp);
|
||||
return [200, { success: true, message: '직원이 생성되었습니다', data: newEmp }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/employees\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/employees\/(\d+)/)?.[1] || '0');
|
||||
const body = JSON.parse(config.data);
|
||||
const emp = s.employees.find((e: any) => e.empId === id);
|
||||
if (!emp) return [404, { success: false, message: 'Not found' }];
|
||||
Object.assign(emp, body);
|
||||
return [200, { success: true, message: '직원이 수정되었습니다', data: emp }];
|
||||
});
|
||||
|
||||
mock.onDelete(/\/employees\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/employees\/(\d+)/)?.[1] || '0');
|
||||
const idx = s.employees.findIndex((e: any) => e.empId === id);
|
||||
if (idx === -1) return [404, { success: false, message: 'Not found' }];
|
||||
s.employees.splice(idx, 1);
|
||||
return [200, { success: true, message: '직원이 삭제되었습니다', data: null }];
|
||||
});
|
||||
|
||||
mock.onPut('/employees/me/password').reply(() => [200, { success: true, message: '비밀번호가 변경되었습니다', data: null }]);
|
||||
|
||||
mock.onGet(/\/employees\/(\d+)\/portfolio/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/employees\/(\d+)\/portfolio/)?.[1] || '0');
|
||||
const emp = s.employees.find((e: any) => e.empId === id);
|
||||
const dispatchList = s.dispatchHistories.filter((d: any) => d.empId === id);
|
||||
const portfolio = dispatchList.map((d: any) => ({
|
||||
dispatchHistory: d,
|
||||
projectName: s.projects.find((p: any) => p.projectId === d.projectId)?.projectName || '',
|
||||
reports: s.reports.filter((r: any) => r.dispatchId === d.dispatchId),
|
||||
resolvedTickets: s.tickets.filter((t: any) => {
|
||||
const project = s.projects.find((p: any) => p.projectId === d.projectId);
|
||||
return project && t.projectId === project.projectId;
|
||||
}),
|
||||
}));
|
||||
return [200, { success: true, message: 'OK', data: portfolio }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/employees\/(\d+)\/dispatch-histories\/(\d+)\/achievements/).reply(() => [200, { success: true, message: 'OK', data: null }]);
|
||||
|
||||
/* Dispatch History CRUD */
|
||||
mock.onGet('/dispatch-histories').reply((config) => {
|
||||
let result = [...s.dispatchHistories];
|
||||
const { empId, projectId } = config.params || {};
|
||||
if (empId) result = result.filter((d: any) => d.empId === Number(empId));
|
||||
if (projectId) result = result.filter((d: any) => d.projectId === Number(projectId));
|
||||
return [200, { success: true, message: 'OK', data: { content: result, totalElements: result.length, totalPages: 1, number: 0, size: result.length } }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/dispatch-histories\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/dispatch-histories\/(\d+)/)?.[1] || '0');
|
||||
const dh = s.dispatchHistories.find((d: any) => d.dispatchId === id);
|
||||
if (!dh) return [404, { success: false, message: 'Not found' }];
|
||||
return [200, { success: true, message: 'OK', data: dh }];
|
||||
});
|
||||
|
||||
mock.onGet('/dispatch-histories/active').reply(() => [200, { success: true, message: 'OK', data: s.dispatchHistories.filter((d: any) => !d.endDate) }]);
|
||||
|
||||
mock.onPut(/\/dispatch-histories\/(\d+)\/end/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/dispatch-histories\/(\d+)\/end/)?.[1] || '0');
|
||||
const dh = s.dispatchHistories.find((d: any) => d.dispatchId === id);
|
||||
if (dh) dh.endDate = new Date().toISOString().split('T')[0];
|
||||
return [200, { success: true, message: '파견이 종료되었습니다', data: dh }];
|
||||
});
|
||||
|
||||
/* Tickets - additional */
|
||||
mock.onPost('/tickets').reply((config) => {
|
||||
const body = JSON.parse(config.data);
|
||||
const newTicket = { ticketId: Date.now(), ...body, status: 'OPEN', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
|
||||
s.tickets.push(newTicket);
|
||||
return [200, { success: true, message: '티켓이 생성되었습니다', data: newTicket }];
|
||||
});
|
||||
|
||||
mock.onDelete(/\/tickets\/(\d+)\/comments\/(\d+)/).reply(() => [200, { success: true, message: '삭제 완료', data: null }]);
|
||||
|
||||
/* Equipment - additional */
|
||||
mock.onPut(/\/equipment\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/equipment\/(\d+)/)?.[1] || '0');
|
||||
const body = JSON.parse(config.data);
|
||||
const equip = s.equipment.find((e: any) => e.equipmentId === id);
|
||||
if (!equip) return [404, { success: false, message: 'Not found' }];
|
||||
Object.assign(equip, body);
|
||||
return [200, { success: true, message: '수정 완료', data: equip }];
|
||||
});
|
||||
|
||||
mock.onDelete(/\/equipment\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/equipment\/(\d+)/)?.[1] || '0');
|
||||
const idx = s.equipment.findIndex((e: any) => e.equipmentId === id);
|
||||
if (idx === -1) return [404, { success: false, message: 'Not found' }];
|
||||
s.equipment.splice(idx, 1);
|
||||
return [200, { success: true, message: '삭제 완료', data: null }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/equipment\/(\d+)\/assignments/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/equipment\/(\d+)\/assignments/)?.[1] || '0');
|
||||
return [200, { success: true, message: 'OK', data: s.assignments.filter((a: any) => a.equipmentId === id) }];
|
||||
});
|
||||
|
||||
mock.onGet(/\/equipment\/(\d+)\/maintenances/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/equipment\/(\d+)\/maintenances/)?.[1] || '0');
|
||||
return [200, { success: true, message: 'OK', data: s.maintenances.filter((m: any) => m.equipmentId === id) }];
|
||||
});
|
||||
|
||||
mock.onPost(/\/equipment\/(\d+)\/maintenance/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/equipment\/(\d+)\/maintenance/)?.[1] || '0');
|
||||
const body = JSON.parse(config.data);
|
||||
const newMaint = { maintenanceId: Date.now(), equipmentId: id, ...body, reportedDate: new Date().toISOString().split('T')[0] };
|
||||
s.maintenances.push(newMaint);
|
||||
return [200, { success: true, message: '등록 완료', data: newMaint }];
|
||||
});
|
||||
|
||||
/* Reports - additional */
|
||||
mock.onGet(/\/reports\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/reports\/(\d+)/)?.[1] || '0');
|
||||
const report = s.reports.find((r: any) => r.reportId === id);
|
||||
if (!report) return [404, { success: false, message: 'Not found' }];
|
||||
return [200, { success: true, message: 'OK', data: report }];
|
||||
});
|
||||
|
||||
mock.onPut(/\/reports\/(\d+)\/status/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/reports\/(\d+)\/status/)?.[1] || '0');
|
||||
const { status } = config.params || {};
|
||||
const report = s.reports.find((r: any) => r.reportId === id);
|
||||
if (report) report.status = status || report.status;
|
||||
return [200, { success: true, message: '상태 변경 완료', data: report }];
|
||||
});
|
||||
|
||||
mock.onDelete(/\/reports\/(\d+)/).reply((config) => {
|
||||
const id = parseInt(config.url?.match(/\/reports\/(\d+)/)?.[1] || '0');
|
||||
const idx = s.reports.findIndex((r: any) => r.reportId === id);
|
||||
if (idx === -1) return [404, { success: false, message: 'Not found' }];
|
||||
s.reports.splice(idx, 1);
|
||||
return [200, { success: true, message: '삭제 완료', data: null }];
|
||||
});
|
||||
|
||||
mock.onGet('/reports/summary').reply(() => [200, { success: true, message: 'OK', data: { submitted: s.reports.filter((r: any) => r.status === 'SUBMITTED').length, approved: s.reports.filter((r: any) => r.status === 'APPROVED').length, rejected: s.reports.filter((r: any) => r.status === 'REJECTED').length, draft: s.reports.filter((r: any) => r.status === 'DRAFT').length } }]);
|
||||
|
||||
/* Projects - my */
|
||||
mock.onGet('/projects/my').reply((config) => {
|
||||
const empId = 3; // default employee
|
||||
const dispatchProjects = s.dispatchHistories
|
||||
.filter((d: any) => d.empId === empId)
|
||||
.map((d: any) => s.projects.find((p: any) => p.projectId === d.projectId))
|
||||
.filter(Boolean);
|
||||
return [200, { success: true, message: 'OK', data: dispatchProjects }];
|
||||
});
|
||||
|
||||
/* Training */
|
||||
mock.onGet('/training').reply((config) => {
|
||||
const { userId } = config.params || {};
|
||||
let result = [...s.training];
|
||||
if (userId) result = result.filter((t: any) => t.userId === Number(userId));
|
||||
return [200, { success: true, message: 'OK', data: result }];
|
||||
});
|
||||
|
||||
/* File upload */
|
||||
mock.onPost('/files/upload').reply(() => [200, { success: true, message: '업로드 완료', data: { url: '/uploads/mock-file.png', name: 'mock-file.png' } }]);
|
||||
|
||||
mock.onAny().passThrough();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import axios, { type AxiosInstance, AxiosError } from 'axios';
|
||||
import type { ApiResponse } from '@/types';
|
||||
import { setCookie, removeCookie } from '@/lib/cookie';
|
||||
import { setupMocks } from '@/mocks/handler';
|
||||
|
||||
interface TypedAxios extends AxiosInstance {
|
||||
get<T>(url: string, config?: Record<string, unknown>): Promise<T>;
|
||||
post<T>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<T>;
|
||||
put<T>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<T>;
|
||||
delete<T>(url: string, config?: Record<string, unknown>): Promise<T>;
|
||||
}
|
||||
|
||||
interface AxiosRequestConfigWithSentinel {
|
||||
sentinel?: boolean;
|
||||
}
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
async (err: AxiosError<ApiResponse<null>>) => {
|
||||
const status = err.response?.status;
|
||||
const originalRequest = err.config;
|
||||
const configWithSentinel = originalRequest as typeof originalRequest & AxiosRequestConfigWithSentinel;
|
||||
|
||||
// Treat 401/403 as auth errors -> refresh or redirect to login
|
||||
if ((status === 401 || status === 403) && typeof window !== 'undefined' && originalRequest && !configWithSentinel.sentinel) {
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (refreshToken) {
|
||||
configWithSentinel.sentinel = true;
|
||||
const res = await axios.post('/api/v1/auth/refresh', { refreshToken });
|
||||
const { accessToken, refreshToken: newRefresh } = res.data.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
if (newRefresh) localStorage.setItem('refreshToken', newRefresh);
|
||||
setCookie('accessToken', accessToken);
|
||||
configWithSentinel.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return instance(configWithSentinel);
|
||||
}
|
||||
} catch {
|
||||
// refresh failed
|
||||
}
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
removeCookie('accessToken');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject({ errorCode: 'UNAUTHORIZED', message: '인증이 만료되었습니다.', status });
|
||||
}
|
||||
|
||||
if ((status === 502 || status === 503) && typeof window !== 'undefined') {
|
||||
import('@/stores').then(({ useParkingStore }) => {
|
||||
useParkingStore.getState().checkParking();
|
||||
});
|
||||
}
|
||||
|
||||
const errorCode = err.response?.data?.errorCode;
|
||||
const message = err.response?.data?.message || err.message;
|
||||
return Promise.reject({ errorCode, message, status });
|
||||
},
|
||||
);
|
||||
|
||||
const apiClient = instance as TypedAxios;
|
||||
|
||||
// Mock — enabled for development (static import ensures synchronous setup)
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'production') {
|
||||
setupMocks(instance);
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,16 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, CommonCode } from '@/types';
|
||||
|
||||
const commonCodeService = {
|
||||
getByGroup(groupCode: string) {
|
||||
return apiClient.get<ApiResponse<CommonCode[]>>('/common-codes', {
|
||||
params: { groupCode },
|
||||
});
|
||||
},
|
||||
|
||||
getAllGroups() {
|
||||
return apiClient.get<ApiResponse<CommonCode[]>>('/common-codes');
|
||||
},
|
||||
};
|
||||
|
||||
export default commonCodeService;
|
||||
@@ -0,0 +1,27 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, Page, DispatchHistory } from '@/types';
|
||||
|
||||
const dispatchHistoryService = {
|
||||
getByEmployee: (empId: number) =>
|
||||
apiClient.get<ApiResponse<DispatchHistory[]>>(`/dispatch-histories/employee/${empId}`),
|
||||
|
||||
getActive: () =>
|
||||
apiClient.get<ApiResponse<DispatchHistory[]>>('/dispatch-histories/active'),
|
||||
|
||||
getAll: (params?: Record<string, unknown>) =>
|
||||
apiClient.get<ApiResponse<Page<DispatchHistory>>>('/dispatch-histories', { params }),
|
||||
|
||||
getById: (id: number) =>
|
||||
apiClient.get<ApiResponse<DispatchHistory>>(`/dispatch-histories/${id}`),
|
||||
|
||||
create: (data: Record<string, unknown>) =>
|
||||
apiClient.post<ApiResponse<DispatchHistory>>('/dispatch-histories', data),
|
||||
|
||||
endDispatch: (id: number) =>
|
||||
apiClient.put<ApiResponse<DispatchHistory>>(`/dispatch-histories/${id}/end`),
|
||||
|
||||
deleteDispatch: (id: number) =>
|
||||
apiClient.delete<ApiResponse<null>>(`/dispatch-histories/${id}`),
|
||||
};
|
||||
|
||||
export default dispatchHistoryService;
|
||||
@@ -0,0 +1,97 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, Employee, EmployeeSearchResult, Page, DispatchHistory, Project } from '@/types';
|
||||
|
||||
const employeeService = {
|
||||
login(credentials: { loginId: string; password: string }) {
|
||||
return apiClient.post<ApiResponse<{ accessToken: string; refreshToken: string; employee: Employee }>>('/auth/login', credentials);
|
||||
},
|
||||
|
||||
refresh(refreshToken: string) {
|
||||
return apiClient.post<ApiResponse<{ accessToken: string; refreshToken: string; employee: Employee }>>('/auth/refresh', { refreshToken });
|
||||
},
|
||||
|
||||
getAll(params?: { roleCode?: string; empStatus?: string; dispatchStatus?: string; page?: number; size?: number }) {
|
||||
return apiClient.get<ApiResponse<Page<Employee>>>('/employees', { params });
|
||||
},
|
||||
|
||||
getOne(empId: number) {
|
||||
return apiClient.get<ApiResponse<Employee>>(`/employees/${empId}`);
|
||||
},
|
||||
|
||||
getMe() {
|
||||
return apiClient.get<ApiResponse<Employee>>('/employees/me');
|
||||
},
|
||||
|
||||
search(keyword: string) {
|
||||
return apiClient.get<ApiResponse<EmployeeSearchResult[]>>('/employees/search', {
|
||||
params: { keyword },
|
||||
});
|
||||
},
|
||||
|
||||
create(data: Partial<Employee>) {
|
||||
return apiClient.post<ApiResponse<Employee>>('/employees', data);
|
||||
},
|
||||
|
||||
update(empId: number, data: Partial<Employee>) {
|
||||
return apiClient.put<ApiResponse<Employee>>(`/employees/${empId}`, data);
|
||||
},
|
||||
|
||||
delete(empId: number) {
|
||||
return apiClient.delete<ApiResponse<void>>(`/employees/${empId}`);
|
||||
},
|
||||
|
||||
getDispatchHistories(empId: number) {
|
||||
return apiClient.get<ApiResponse<DispatchHistory[]>>(`/employees/${empId}/dispatch-histories`);
|
||||
},
|
||||
|
||||
getPortfolio(empId: number) {
|
||||
return apiClient.get<ApiResponse<PortfolioEntry[]>>(`/employees/${empId}/portfolio`);
|
||||
},
|
||||
|
||||
updateAchievements(empId: number, dispatchId: number, achievements: string) {
|
||||
return apiClient.put<ApiResponse<void>>(`/employees/${empId}/dispatch-histories/${dispatchId}/achievements`, { achievements });
|
||||
},
|
||||
|
||||
createDispatch(data: CreateDispatchRequest) {
|
||||
return apiClient.post<ApiResponse<DispatchHistory>>('/dispatch-histories', data);
|
||||
},
|
||||
|
||||
endDispatch(dispatchId: number) {
|
||||
return apiClient.put<ApiResponse<DispatchHistory>>(`/dispatch-histories/${dispatchId}/end`);
|
||||
},
|
||||
|
||||
getActiveDispatches() {
|
||||
return apiClient.get<ApiResponse<DispatchHistory[]>>('/dispatch-histories/active');
|
||||
},
|
||||
|
||||
changePassword(currentPassword: string, newPassword: string) {
|
||||
return apiClient.put<ApiResponse<void>>('/employees/me/password', { currentPassword, newPassword });
|
||||
},
|
||||
};
|
||||
|
||||
export interface PortfolioEntry {
|
||||
dispatchId: number;
|
||||
projectName: string;
|
||||
roleTitle: string;
|
||||
contributionRate: number;
|
||||
individualTech: string;
|
||||
performedTasks: string;
|
||||
achievements: string | null;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
internalRating: number | null;
|
||||
reports: { targetDate: string; reportType: string; completedTasks: string }[];
|
||||
resolvedTickets: { ticketId: number; title: string; category: string; resolutionNote: string | null }[];
|
||||
}
|
||||
|
||||
export interface CreateDispatchRequest {
|
||||
employeeId: number;
|
||||
projectId: number;
|
||||
roleTitle: string;
|
||||
contributionRate: number;
|
||||
individualTech: string;
|
||||
performedTasks: string;
|
||||
startDate?: string;
|
||||
}
|
||||
|
||||
export default employeeService;
|
||||
@@ -0,0 +1,52 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, Page, Equipment, EquipmentAssignment, EquipmentMaintenance } from '@/types';
|
||||
|
||||
const equipmentService = {
|
||||
getAll(params?: { type?: string; status?: string; manufacturer?: string; page?: number; size?: number }) {
|
||||
return apiClient.get<ApiResponse<Page<Equipment>>>('/equipment', { params });
|
||||
},
|
||||
|
||||
getOne(equipId: number) {
|
||||
return apiClient.get<ApiResponse<Equipment>>(`/equipment/${equipId}`);
|
||||
},
|
||||
|
||||
create(data: Partial<Equipment>) {
|
||||
return apiClient.post<ApiResponse<Equipment>>('/equipment', data);
|
||||
},
|
||||
|
||||
update(equipId: number, data: Partial<Equipment>) {
|
||||
return apiClient.put<ApiResponse<Equipment>>(`/equipment/${equipId}`, data);
|
||||
},
|
||||
|
||||
delete(equipId: number) {
|
||||
return apiClient.delete<ApiResponse<void>>(`/equipment/${equipId}`);
|
||||
},
|
||||
|
||||
assign(equipId: number, assigneeId: number, conditionNote: string) {
|
||||
return apiClient.post<ApiResponse<EquipmentAssignment>>(`/equipment/${equipId}/assign`, {
|
||||
assigneeId, conditionNote,
|
||||
});
|
||||
},
|
||||
|
||||
return_(equipId: number, data: { conditionNote?: string }) {
|
||||
return apiClient.put<ApiResponse<EquipmentAssignment>>(`/equipment/${equipId}/return`, data);
|
||||
},
|
||||
|
||||
getAssignments(equipId: number) {
|
||||
return apiClient.get<ApiResponse<EquipmentAssignment[]>>(`/equipment/${equipId}/assignments`);
|
||||
},
|
||||
|
||||
getMaintenances(equipId: number) {
|
||||
return apiClient.get<ApiResponse<EquipmentMaintenance[]>>(`/equipment/${equipId}/maintenances`);
|
||||
},
|
||||
|
||||
addMaintenance(equipId: number, data: Partial<EquipmentMaintenance>) {
|
||||
return apiClient.post<ApiResponse<EquipmentMaintenance>>(`/equipment/${equipId}/maintenance`, data);
|
||||
},
|
||||
|
||||
getSummary() {
|
||||
return apiClient.get<ApiResponse<Record<string, number>>>('/equipment/summary');
|
||||
},
|
||||
};
|
||||
|
||||
export default equipmentService;
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as apiClient } from './apiClient';
|
||||
export { default as ticketService } from './ticketService';
|
||||
export { default as equipmentService } from './equipmentService';
|
||||
export { default as reportService } from './reportService';
|
||||
export { default as employeeService } from './employeeService';
|
||||
export { default as trainingService } from './trainingService';
|
||||
export { default as notificationService } from './notificationService';
|
||||
@@ -0,0 +1,20 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, Notification } from '@/types';
|
||||
|
||||
const notificationService = {
|
||||
getUnreadCount() {
|
||||
return apiClient.get<ApiResponse<{ count: number }>>('/notifications/unread');
|
||||
},
|
||||
|
||||
getAll(page = 0, size = 20) {
|
||||
return apiClient.get<ApiResponse<Notification[]>>('/notifications', {
|
||||
params: { page, size },
|
||||
});
|
||||
},
|
||||
|
||||
markAsRead(notificationId: number) {
|
||||
return apiClient.put<ApiResponse<void>>(`/notifications/${notificationId}/read`);
|
||||
},
|
||||
};
|
||||
|
||||
export default notificationService;
|
||||
@@ -0,0 +1,32 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, Project } from '@/types';
|
||||
|
||||
const projectService = {
|
||||
getMyProjects(): Promise<ApiResponse<Project[]>> {
|
||||
return apiClient.get('/projects/my');
|
||||
},
|
||||
|
||||
getProject(projectId: number): Promise<ApiResponse<Project>> {
|
||||
return apiClient.get(`/projects/${projectId}`);
|
||||
},
|
||||
|
||||
getAll(status?: string): Promise<ApiResponse<Project[]>> {
|
||||
return apiClient.get('/projects', {
|
||||
params: status ? { status } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
create(data: Record<string, unknown>): Promise<ApiResponse<Project>> {
|
||||
return apiClient.post('/projects', data);
|
||||
},
|
||||
|
||||
update(projectId: number, data: Record<string, unknown>): Promise<ApiResponse<Project>> {
|
||||
return apiClient.put(`/projects/${projectId}`, data);
|
||||
},
|
||||
|
||||
deleteProject(projectId: number): Promise<ApiResponse<void>> {
|
||||
return apiClient.delete(`/projects/${projectId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default projectService;
|
||||
@@ -0,0 +1,44 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, Page, ReportSummary, WorkReport } from '@/types';
|
||||
|
||||
const reportService = {
|
||||
getAll(params?: { reportType?: string; status?: string; userId?: number; page?: number; size?: number }) {
|
||||
return apiClient.get<ApiResponse<Page<WorkReport>>>('/reports', { params });
|
||||
},
|
||||
|
||||
getOne(reportId: number) {
|
||||
return apiClient.get<ApiResponse<WorkReport>>(`/reports/${reportId}`);
|
||||
},
|
||||
|
||||
create(data: Partial<WorkReport>) {
|
||||
return apiClient.post<ApiResponse<WorkReport>>('/reports', data);
|
||||
},
|
||||
|
||||
updateStatus(reportId: number, status: string) {
|
||||
return apiClient.put<ApiResponse<WorkReport>>(`/reports/${reportId}/status`, null, {
|
||||
params: { status },
|
||||
});
|
||||
},
|
||||
|
||||
review(reportId: number, status: 'APPROVED' | 'REJECTED', reviewComment: string) {
|
||||
return apiClient.put<ApiResponse<WorkReport>>(`/reports/${reportId}/review`, {
|
||||
status, reviewComment,
|
||||
});
|
||||
},
|
||||
|
||||
delete(reportId: number) {
|
||||
return apiClient.delete<ApiResponse<void>>(`/reports/${reportId}`);
|
||||
},
|
||||
|
||||
getSummary() {
|
||||
return apiClient.get<ApiResponse<ReportSummary>>('/reports/summary');
|
||||
},
|
||||
|
||||
getSrAutoComplete(targetDate: string) {
|
||||
return apiClient.get<ApiResponse<string[]>>('/reports/sr-auto-complete', {
|
||||
params: { targetDate },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default reportService;
|
||||
@@ -0,0 +1,56 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, Page, IssueTicket, TicketComment } from '@/types';
|
||||
|
||||
export interface TicketFilterParams {
|
||||
category?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
searchKeyword?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const ticketService = {
|
||||
getTickets(params?: TicketFilterParams) {
|
||||
return apiClient.get<ApiResponse<Page<IssueTicket>>>('/tickets', { params });
|
||||
},
|
||||
|
||||
getTicket(ticketId: number) {
|
||||
return apiClient.get<ApiResponse<IssueTicket>>(`/tickets/${ticketId}`);
|
||||
},
|
||||
|
||||
createTicket(data: Partial<IssueTicket>) {
|
||||
return apiClient.post<ApiResponse<IssueTicket>>('/tickets', data);
|
||||
},
|
||||
|
||||
updateStatus(ticketId: number, status: string, assigneeId?: number) {
|
||||
return apiClient.put<ApiResponse<IssueTicket>>(`/tickets/${ticketId}/status`, null, {
|
||||
params: { status, assigneeId },
|
||||
});
|
||||
},
|
||||
|
||||
resolveTicket(ticketId: number, resolutionNote: string) {
|
||||
return apiClient.put<ApiResponse<IssueTicket>>(`/tickets/${ticketId}/resolve`, { resolutionNote });
|
||||
},
|
||||
|
||||
deleteTicket(ticketId: number) {
|
||||
return apiClient.delete<ApiResponse<void>>(`/tickets/${ticketId}`);
|
||||
},
|
||||
|
||||
getComments(ticketId: number) {
|
||||
return apiClient.get<ApiResponse<TicketComment[]>>(`/tickets/${ticketId}/comments`);
|
||||
},
|
||||
|
||||
addComment(ticketId: number, commentText: string, attachedFileUrl?: string) {
|
||||
return apiClient.post<ApiResponse<TicketComment>>(`/tickets/${ticketId}/comments`, {
|
||||
commentText,
|
||||
attachedFileUrl,
|
||||
});
|
||||
},
|
||||
|
||||
deleteComment(ticketId: number, commentId: number) {
|
||||
return apiClient.delete<ApiResponse<void>>(`/tickets/${ticketId}/comments/${commentId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default ticketService;
|
||||
@@ -0,0 +1,29 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, TrainingProgress } from '@/types';
|
||||
|
||||
const trainingService = {
|
||||
getMyProgress() {
|
||||
return apiClient.get<ApiResponse<TrainingProgress[]>>('/training/me');
|
||||
},
|
||||
|
||||
getByUser(userId: number) {
|
||||
return apiClient.get<ApiResponse<TrainingProgress[]>>('/training', {
|
||||
params: { userId },
|
||||
});
|
||||
},
|
||||
|
||||
update(progressId: number, data: { progressRate?: number; status?: string }) {
|
||||
return apiClient.put<ApiResponse<TrainingProgress>>(`/training/${progressId}`, data);
|
||||
},
|
||||
|
||||
getSummary() {
|
||||
return apiClient.get<ApiResponse<Array<{
|
||||
courseName: string;
|
||||
totalEnrolled: number;
|
||||
completedCount: number;
|
||||
avgProgress: number;
|
||||
}>>>('/training/summary');
|
||||
},
|
||||
};
|
||||
|
||||
export default trainingService;
|
||||
@@ -0,0 +1,12 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { ApiResponse, WakeRequest, WakeResponse } from '@/types';
|
||||
|
||||
const wakeService = {
|
||||
requestWake: (data: WakeRequest) =>
|
||||
apiClient.post<ApiResponse<WakeResponse>>('/wake', data),
|
||||
|
||||
checkStatus: () =>
|
||||
apiClient.get<ApiResponse<{ parked: boolean }>>('/health'),
|
||||
};
|
||||
|
||||
export default wakeService;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Employee, LoginRequest } from '@/types';
|
||||
import employeeService from '@/services/employeeService';
|
||||
import { setCookie, removeCookie } from '@/lib/cookie';
|
||||
|
||||
interface AuthState {
|
||||
user: Employee | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (credentials: LoginRequest) => Promise<Employee>;
|
||||
logout: () => void;
|
||||
fetchMe: () => Promise<void>;
|
||||
setUser: (user: Employee) => void;
|
||||
tryRefresh: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: async (credentials) => {
|
||||
const res = await employeeService.login(credentials);
|
||||
const { accessToken, refreshToken, employee } = res.data;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
|
||||
setCookie('accessToken', accessToken);
|
||||
}
|
||||
set({ user: employee, accessToken, refreshToken, isAuthenticated: true });
|
||||
return employee;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
removeCookie('accessToken');
|
||||
}
|
||||
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
fetchMe: async () => {
|
||||
try {
|
||||
const res = await employeeService.getMe();
|
||||
set({ user: res.data, isAuthenticated: true });
|
||||
} catch {
|
||||
get().logout();
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user) => set({ user, isAuthenticated: true }),
|
||||
|
||||
tryRefresh: async () => {
|
||||
try {
|
||||
const refreshToken = get().refreshToken;
|
||||
if (!refreshToken) return false;
|
||||
const res = await employeeService.refresh(refreshToken);
|
||||
const { accessToken, refreshToken: newRefresh } = res.data;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
if (newRefresh) localStorage.setItem('refreshToken', newRefresh);
|
||||
setCookie('accessToken', accessToken);
|
||||
}
|
||||
set({ accessToken, refreshToken: newRefresh || refreshToken });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useAuthStore } from './authStore';
|
||||
export { useNotificationStore } from './notificationStore';
|
||||
export { useParkingStore } from './parkingStore';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
import notificationService from '@/services/notificationService';
|
||||
import type { Notification } from '@/types';
|
||||
|
||||
interface NotificationState {
|
||||
unreadCount: number;
|
||||
notifications: Notification[];
|
||||
polling: boolean;
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
fetchNotifications: () => Promise<void>;
|
||||
markAsRead: (id: number) => Promise<void>;
|
||||
startPolling: () => void;
|
||||
stopPolling: () => void;
|
||||
}
|
||||
|
||||
let pollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export const useNotificationStore = create<NotificationState>()((set, get) => ({
|
||||
unreadCount: 0,
|
||||
notifications: [],
|
||||
polling: false,
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
try {
|
||||
const res = await notificationService.getUnreadCount();
|
||||
set({ unreadCount: res.data.count });
|
||||
} catch { /* ignore polling errors */ }
|
||||
},
|
||||
|
||||
fetchNotifications: async () => {
|
||||
try {
|
||||
const res = await notificationService.getAll();
|
||||
set({ notifications: res.data });
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
markAsRead: async (id) => {
|
||||
await notificationService.markAsRead(id);
|
||||
get().fetchUnreadCount();
|
||||
get().fetchNotifications();
|
||||
},
|
||||
|
||||
startPolling: () => {
|
||||
if (pollingTimer) return;
|
||||
get().fetchUnreadCount();
|
||||
pollingTimer = setInterval(() => {
|
||||
get().fetchUnreadCount();
|
||||
}, 30000);
|
||||
set({ polling: true });
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = null;
|
||||
}
|
||||
set({ polling: false });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,77 @@
|
||||
import { create } from 'zustand';
|
||||
import wakeService from '@/services/wakeService';
|
||||
|
||||
interface ParkingState {
|
||||
parked: boolean;
|
||||
checking: boolean;
|
||||
wakeLoading: boolean;
|
||||
wakeSent: boolean;
|
||||
wakeMessage: string;
|
||||
checkParking: () => Promise<void>;
|
||||
requestWake: (reason: string) => Promise<void>;
|
||||
dismissWake: () => void;
|
||||
}
|
||||
|
||||
const isParkingHours = (): boolean => {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const hour = now.getHours();
|
||||
// Weekend (Sat=6, Sun=0) → always parked
|
||||
if (day === 0 || day === 6) return true;
|
||||
// Weekday 20:00 ~ 08:00 → parked
|
||||
if (hour < 8 || hour >= 20) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const useParkingStore = create<ParkingState>()((set, get) => ({
|
||||
parked: false,
|
||||
checking: true,
|
||||
wakeLoading: false,
|
||||
wakeSent: false,
|
||||
wakeMessage: '',
|
||||
|
||||
checkParking: async () => {
|
||||
set({ checking: true });
|
||||
try {
|
||||
const res = await wakeService.checkStatus();
|
||||
if (res.data.parked) {
|
||||
set({ parked: true, checking: false });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Network error → check if within parking hours
|
||||
if (isParkingHours()) {
|
||||
set({ parked: true, checking: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
set({ parked: false, checking: false });
|
||||
},
|
||||
|
||||
requestWake: async (reason: string) => {
|
||||
set({ wakeLoading: true, wakeSent: false, wakeMessage: '' });
|
||||
try {
|
||||
const name = typeof window !== 'undefined'
|
||||
? localStorage.getItem('userName') || 'Unknown'
|
||||
: 'Unknown';
|
||||
const res = await wakeService.requestWake({
|
||||
requestedBy: name,
|
||||
reason,
|
||||
requestedAt: new Date().toISOString(),
|
||||
});
|
||||
set({
|
||||
wakeLoading: false,
|
||||
wakeSent: true,
|
||||
wakeMessage: res.data.message,
|
||||
});
|
||||
} catch {
|
||||
set({
|
||||
wakeLoading: false,
|
||||
wakeSent: true,
|
||||
wakeMessage: '요청이 전송되었습니다. 관리자가 확인 후 서버를 기동합니다.',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
dismissWake: () => set({ wakeSent: false, wakeMessage: '' }),
|
||||
}));
|
||||
@@ -0,0 +1,318 @@
|
||||
// ============================================
|
||||
// 공통 응답 타입
|
||||
// ============================================
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: T;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 공용 코드/상태 유니온
|
||||
// ============================================
|
||||
export type RoleCode = 'ADMIN' | 'MANAGER' | 'EMPLOYEE';
|
||||
export type EmpStatus = 'ACTIVE' | 'ON_LEAVE' | 'RESIGNED';
|
||||
export type DispatchStatus = 'WAITING' | 'DISPATCHED';
|
||||
|
||||
export type TicketStatus = 'OPEN' | 'ASSIGNED' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
|
||||
export type TicketCategory = 'BUG' | 'FEATURE' | 'SUPPORT' | 'QA';
|
||||
export type TicketPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'CRITICAL';
|
||||
|
||||
export type ReportType = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'RETURN';
|
||||
export type ReportStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'REJECTED';
|
||||
|
||||
export type EquipmentType = 'LAPTOP' | 'MONITOR' | 'TEST_DEVICE' | 'ACCESSORY';
|
||||
export type EquipmentStatus = 'AVAILABLE' | 'IN_USE' | 'REPAIRING' | 'RETIRED' | 'LOST';
|
||||
export type MaintenanceType = 'REPAIR' | 'UPGRADE' | 'INSPECTION' | 'DISPOSAL';
|
||||
|
||||
export type ProjectType = 'SI' | 'SM' | 'MIGRATION';
|
||||
export type ContractType = 'TURN_KEY' | 'MM';
|
||||
export type ProjectStatus = 'PREPARING' | 'ONGOING' | 'COMPLETED' | 'HOLD';
|
||||
export type WorkLocationType = 'ON_SITE' | 'REMOTE' | 'HYBRID';
|
||||
|
||||
export type TrainingStatus = 'NOT_STARTED' | 'ONGOING' | 'COMPLETED';
|
||||
|
||||
// ============================================
|
||||
// Employee (직원)
|
||||
// ============================================
|
||||
export interface Employee {
|
||||
empId: number;
|
||||
loginId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roleCode: RoleCode;
|
||||
empStatus: EmpStatus;
|
||||
dispatchStatus: DispatchStatus;
|
||||
joinedDate: string;
|
||||
leftDate: string | null;
|
||||
}
|
||||
|
||||
export interface EmployeeSearchResult {
|
||||
empId: number;
|
||||
name: string;
|
||||
roleCode: RoleCode;
|
||||
dispatchStatus: DispatchStatus;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Project (프로젝트)
|
||||
// ============================================
|
||||
export interface Project {
|
||||
projectId: number;
|
||||
projectType: ProjectType;
|
||||
projectName: string;
|
||||
contractCompany: string;
|
||||
endUserCompany: string;
|
||||
contractType: ContractType;
|
||||
status: ProjectStatus;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
workLocationType: WorkLocationType;
|
||||
workAddress: string;
|
||||
techEnvironment: string;
|
||||
mainTaskDesc: string;
|
||||
internalManagerId: number | null;
|
||||
internalManager?: { empId: number; name: string; email: string; } | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DispatchHistory (파견 이력 / 포트폴리오)
|
||||
// ============================================
|
||||
export interface DispatchHistory {
|
||||
dispatchId: number;
|
||||
empId: number;
|
||||
projectId: number;
|
||||
projectName?: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
roleTitle: string;
|
||||
contributionRate: number;
|
||||
individualTech: string;
|
||||
performedTasks: string;
|
||||
achievements: string | null;
|
||||
clientFeedback: string | null;
|
||||
internalRating: number | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// IssueTicket (SR 티켓)
|
||||
// ============================================
|
||||
export interface IssueTicket {
|
||||
ticketId: number;
|
||||
projectId: number;
|
||||
projectName?: string;
|
||||
requesterId: number;
|
||||
requesterName?: string;
|
||||
assigneeId: number | null;
|
||||
assigneeName?: string | null;
|
||||
category: TicketCategory;
|
||||
priority: TicketPriority;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TicketStatus;
|
||||
dueDate: string | null;
|
||||
resolvedAt: string | null;
|
||||
resolutionNote: string | null;
|
||||
slaWarnFlag: 'Y' | 'N';
|
||||
isImminent?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TicketComment {
|
||||
commentId: number;
|
||||
ticketId: number;
|
||||
userId: number;
|
||||
userName?: string;
|
||||
commentText: string;
|
||||
attachedFileUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WorkReport (업무 보고서)
|
||||
// ============================================
|
||||
export interface WorkReport {
|
||||
reportId: number;
|
||||
dispatchId: number;
|
||||
userId: number;
|
||||
userName?: string;
|
||||
reportType: ReportType;
|
||||
targetDate: string;
|
||||
completedTasks: string;
|
||||
plannedTasks: string;
|
||||
issuesAndRisks: string | null;
|
||||
supportRequested: string | null;
|
||||
status: ReportStatus;
|
||||
workHours?: number | null;
|
||||
blockerFlag?: string;
|
||||
artifacts?: string;
|
||||
reviewerId: number | null;
|
||||
reviewerName?: string | null;
|
||||
reviewComment: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Equipment (IT 자산)
|
||||
// ============================================
|
||||
export interface EquipmentImage {
|
||||
url: string;
|
||||
name: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface Equipment {
|
||||
equipmentId: number;
|
||||
equipmentType: EquipmentType;
|
||||
manufacturer: string;
|
||||
modelName: string;
|
||||
serialNumber: string;
|
||||
assetCode: string;
|
||||
specifications: Record<string, string>;
|
||||
status: EquipmentStatus;
|
||||
purchaseDate: string;
|
||||
warrantyExpDate: string;
|
||||
version: number;
|
||||
images?: EquipmentImage[] | string;
|
||||
}
|
||||
|
||||
export interface EquipmentAssignment {
|
||||
assignmentId: number;
|
||||
equipmentId: number;
|
||||
userId: number;
|
||||
userName?: string;
|
||||
assignedDate: string;
|
||||
returnedDate: string | null;
|
||||
condOnAssign: string;
|
||||
condOnReturn: string | null;
|
||||
}
|
||||
|
||||
export interface EquipmentMaintenance {
|
||||
maintenanceId: number;
|
||||
equipmentId: number;
|
||||
reportedBy: number;
|
||||
reporterName?: string;
|
||||
maintenanceType: MaintenanceType;
|
||||
issueDescription: string;
|
||||
actionTaken: string | null;
|
||||
cost: number;
|
||||
reportedDate: string;
|
||||
resolvedDate: string | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Training (교육 진도)
|
||||
// ============================================
|
||||
export interface TrainingProgress {
|
||||
progressId: number;
|
||||
userId: number;
|
||||
courseName: string;
|
||||
progressRate: number;
|
||||
status: TrainingStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TrainingSummary {
|
||||
courseName: string;
|
||||
totalEnrolled: number;
|
||||
completedCount: number;
|
||||
avgProgress: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Notification (알림)
|
||||
// ============================================
|
||||
export interface Notification {
|
||||
notificationId: number;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
isRead: boolean;
|
||||
linkUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CalendarEvent (통합 일정)
|
||||
// ============================================
|
||||
export interface CalendarEvent {
|
||||
eventType: 'TICKET_DUE' | 'REPORT_DUE' | 'DISPATCH_START' | 'DISPATCH_END';
|
||||
sourceId: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
eventDate: string;
|
||||
color: string;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ReportSummary (보고서 결재 요약)
|
||||
// ============================================
|
||||
export interface ReportSummary {
|
||||
submitted: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
draft: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Parking (서버 파킹)
|
||||
// ============================================
|
||||
export interface ParkingStatus {
|
||||
parked: boolean;
|
||||
message: string;
|
||||
resumeTime: string;
|
||||
parkingSchedule: string;
|
||||
}
|
||||
|
||||
export interface WakeRequest {
|
||||
requestedBy: string;
|
||||
reason: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
export interface WakeResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
estimatedWakeTime: string | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Auth
|
||||
// ============================================
|
||||
export interface LoginRequest {
|
||||
loginId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
employee: Employee;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Common Code
|
||||
// ============================================
|
||||
export interface CommonCode {
|
||||
codeId: number;
|
||||
groupCode: string;
|
||||
codeValue: string;
|
||||
codeName: string;
|
||||
optionVal1: string | null;
|
||||
optionVal2: string | null;
|
||||
sortOrder: number;
|
||||
isActive: 'Y' | 'N';
|
||||
}
|
||||
Reference in New Issue
Block a user