init: SR System — Helpdesk & ITAM Portal
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user