feat: approval workflow with approver/backupApprover
This commit is contained in:
@@ -4,18 +4,26 @@ import React, { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, Clock, User, MessageSquare, Paperclip, Send, CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import ticketService from '@/services/ticketService';
|
||||
import apiClient from '@/services/apiClient';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import type { IssueTicket, TicketComment } from '@/types';
|
||||
|
||||
const statusFlow = ['OPEN', 'ASSIGNED', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'];
|
||||
const approvalStatuses = ['PENDING_APPROVAL', 'APPROVED', 'REJECTED'];
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
OPEN: 'bg-status-open-bg text-status-open-text',
|
||||
ASSIGNED: 'bg-status-pending-bg text-status-pending-text',
|
||||
IN_PROGRESS: 'bg-status-pending-bg text-status-pending-text',
|
||||
RESOLVED: 'bg-status-resolved-bg text-status-resolved-text',
|
||||
CLOSED: 'bg-gray-100 text-text-muted',
|
||||
PENDING_APPROVAL: 'bg-amber-50 text-amber-700',
|
||||
APPROVED: 'bg-emerald-50 text-emerald-700',
|
||||
REJECTED: 'bg-red-50 text-red-700',
|
||||
};
|
||||
const statusLabel: Record<string, string> = {
|
||||
OPEN: 'Open', ASSIGNED: 'Assigned', IN_PROGRESS: 'In Progress', RESOLVED: 'Resolved', CLOSED: 'Closed',
|
||||
PENDING_APPROVAL: '결재 대기', APPROVED: '승인 완료', REJECTED: '반려',
|
||||
};
|
||||
|
||||
const categoryLabel: Record<string, string> = {
|
||||
@@ -41,10 +49,16 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
||||
const ticketId = parseInt(resolvedParams.id);
|
||||
const [ticket, setTicket] = useState<IssueTicket | null>(null);
|
||||
const [comments, setComments] = useState<TicketComment[]>([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [status, setStatus] = useState('OPEN');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
ticketService.getTicket(ticketId),
|
||||
@@ -80,6 +94,24 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
||||
} catch { alert('댓글 등록 실패'); }
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
const res: any = await apiClient.post(`/tickets/${ticketId}/approve`);
|
||||
if (res.success) setTicket(res.data);
|
||||
} catch (err: any) { alert(err.message || '승인 실패'); }
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
try {
|
||||
const res: any = await apiClient.post(`/tickets/${ticketId}/reject`, { reason: rejectReason });
|
||||
if (res.success) {
|
||||
setTicket(res.data);
|
||||
setShowRejectDialog(false);
|
||||
setRejectReason('');
|
||||
}
|
||||
} catch (err: any) { alert(err.message || '반려 실패'); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-screen text-text-muted text-sm">Loading...</div>;
|
||||
if (!ticket) return <div className="flex items-center justify-center h-screen text-text-muted text-sm">Ticket not found</div>;
|
||||
|
||||
@@ -133,6 +165,24 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.status === 'PENDING_APPROVAL' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-amber-800">⏳ 결재 대기</p>
|
||||
<p className="text-amber-700 mt-1">승인자가 검토 중입니다.</p>
|
||||
</div>
|
||||
)}
|
||||
{ticket.status === 'APPROVED' && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-emerald-800">✅ 승인 완료</p>
|
||||
</div>
|
||||
)}
|
||||
{ticket.status === 'REJECTED' && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-red-800">❌ 반려</p>
|
||||
{ticket.resolutionNote && <p className="text-red-700 mt-1">사유: {ticket.resolutionNote}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.category === 'VACATION' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 text-sm">
|
||||
<p className="font-semibold text-amber-800">휴가 신청</p>
|
||||
@@ -229,6 +279,20 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ticket.status === 'PENDING_APPROVAL' && user?.roleCode !== 'EMPLOYEE' && (
|
||||
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-5">
|
||||
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">결재 처리</h4>
|
||||
<div className="space-y-2">
|
||||
<button onClick={handleApprove} className="w-full bg-emerald-500 text-white px-4 py-2 rounded-xl text-sm font-semibold hover:bg-emerald-600 transition-colors">
|
||||
✅ Approve
|
||||
</button>
|
||||
<button onClick={() => setShowRejectDialog(true)} className="w-full bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded-xl text-sm font-semibold hover:bg-red-100 transition-colors">
|
||||
❌ Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-surface rounded-2xl border border-border p-4 sm:p-5">
|
||||
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-4">Progress</h4>
|
||||
<div className="space-y-3">
|
||||
@@ -257,6 +321,25 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRejectDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full mx-4 p-6">
|
||||
<h3 className="text-lg font-bold mb-1">반려 사유</h3>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg text-sm mt-3 mb-4 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
placeholder="반려 사유를 입력하세요"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setShowRejectDialog(false)} className="flex-1 px-4 py-2 rounded-xl border border-border text-sm hover:bg-gray-50 transition-colors">취소</button>
|
||||
<button onClick={handleReject} disabled={!rejectReason.trim()} className="flex-1 px-4 py-2 rounded-xl bg-red-500 text-white text-sm font-semibold hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">반려</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+200
-72
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Search, Plus, X } from 'lucide-react';
|
||||
import { Search, Plus, X, ListChecks } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import TicketCard from '@/components/issues/KanbanBoard';
|
||||
@@ -15,6 +15,9 @@ const columns = [
|
||||
{ key: 'OPEN', label: 'Open', color: 'bg-status-open-text' },
|
||||
{ key: 'ASSIGNED', label: 'Assigned', color: 'bg-status-pending-text' },
|
||||
{ key: 'IN_PROGRESS', label: 'In Progress', color: 'bg-status-pending-text' },
|
||||
{ key: 'PENDING_APPROVAL', label: 'Pending Approval', color: 'bg-amber-500' },
|
||||
{ key: 'APPROVED', label: 'Approved', color: 'bg-emerald-500' },
|
||||
{ key: 'REJECTED', label: 'Rejected', color: 'bg-red-500' },
|
||||
{ key: 'DONE', label: 'Done', color: 'bg-status-resolved-text' },
|
||||
];
|
||||
|
||||
@@ -28,6 +31,7 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
});
|
||||
const [extraFields, setExtraFields] = useState<Record<string, string>>({});
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [approvers, setApprovers] = useState<{empId: number; name: string; email: string}[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -35,6 +39,9 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
apiClient.get<{ success: boolean; data: Project[] }>('/projects')
|
||||
.then((res) => setProjects(res.data))
|
||||
.catch(console.error);
|
||||
apiClient.get<{ success: boolean; data: {empId: number; name: string; email: string}[] }>('/employees/approvers')
|
||||
.then((res) => setApprovers(res.data))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const updateField = (field: string, value: string) => {
|
||||
@@ -59,6 +66,8 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
}
|
||||
};
|
||||
|
||||
const needsApprover = ['VACATION', 'EXPENSE', 'EQUIPMENT_REQUEST', 'GENERAL'].includes(formData.category);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errors: Record<string, string> = {};
|
||||
@@ -77,6 +86,9 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
if (!extraFields.equipmentType?.trim()) errors.equipmentType = '장비 유형을 입력해주세요';
|
||||
if (!extraFields.urgency) errors.urgency = '긴급도를 선택해주세요';
|
||||
}
|
||||
if (needsApprover && !extraFields.approverId) {
|
||||
errors.approverId = '승인자를 선택해주세요';
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
@@ -111,6 +123,8 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
projectId: formData.projectId ? parseInt(formData.projectId) : undefined,
|
||||
attachmentUrl: extraFields.receiptUrl || undefined,
|
||||
attachmentName: extraFields.receiptName || undefined,
|
||||
approverId: extraFields.approverId ? parseInt(extraFields.approverId) : undefined,
|
||||
backupApproverId: extraFields.backupApproverId ? parseInt(extraFields.backupApproverId) : undefined,
|
||||
});
|
||||
onSuccess();
|
||||
onClose();
|
||||
@@ -175,6 +189,8 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
delete next.amount;
|
||||
delete next.equipmentType;
|
||||
delete next.urgency;
|
||||
delete next.approverId;
|
||||
delete next.backupApproverId;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
@@ -337,6 +353,41 @@ function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approver dropdowns for VACATION/EXPENSE/EQUIPMENT_REQUEST/GENERAL */}
|
||||
{needsApprover && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">승인자 (사수) <span className="text-red-500">*</span></label>
|
||||
<select
|
||||
value={extraFields.approverId || ''}
|
||||
onChange={(e) => updateExtraField('approverId', e.target.value)}
|
||||
className={`w-full px-3 py-2.5 border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary ${
|
||||
fieldErrors.approverId ? 'border-error' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{approvers.map((a) => (
|
||||
<option key={a.empId} value={a.empId}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{renderFieldError('approverId')}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">부승인자 (부사수, 선택)</label>
|
||||
<select
|
||||
value={extraFields.backupApproverId || ''}
|
||||
onChange={(e) => updateExtraField('backupApproverId', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
{approvers.map((a) => (
|
||||
<option key={a.empId} value={a.empId}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">
|
||||
Description <span className="text-red-500">*</span>
|
||||
@@ -385,6 +436,10 @@ export default function IssuesPage() {
|
||||
const [filterPriority, setFilterPriority] = useState('ALL');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [mobileColumn, setMobileColumn] = useState<string>(columns[0].key);
|
||||
const [approvalMode, setApprovalMode] = useState(false);
|
||||
const [pendingTickets, setPendingTickets] = useState<IssueTicket[]>([]);
|
||||
const [pendingLoading, setPendingLoading] = useState(false);
|
||||
const [userRole, setUserRole] = useState<string>('');
|
||||
|
||||
const { codes: priorities } = useCommonCodes('TICKET_PRIORITY');
|
||||
const { codes: categories } = useCommonCodes('TICKET_CATEGORY');
|
||||
@@ -394,6 +449,23 @@ export default function IssuesPage() {
|
||||
if (q) setSearchKeyword(q);
|
||||
}, [searchParams]);
|
||||
|
||||
// Fetch user role on mount
|
||||
useEffect(() => {
|
||||
const storedRole = localStorage.getItem('userRole');
|
||||
if (storedRole) setUserRole(storedRole);
|
||||
}, []);
|
||||
|
||||
// Fetch pending approvals when approvalMode toggles on
|
||||
useEffect(() => {
|
||||
if (approvalMode) {
|
||||
setPendingLoading(true);
|
||||
apiClient.get<{ success: boolean; data: IssueTicket[] }>('/tickets/pending')
|
||||
.then((res) => setPendingTickets(res.data))
|
||||
.catch(console.error)
|
||||
.finally(() => setPendingLoading(false));
|
||||
}
|
||||
}, [approvalMode]);
|
||||
|
||||
useEffect(() => {
|
||||
ticketService.getTickets({ size: 100 }).then((res) => {
|
||||
setTickets(res.data.content);
|
||||
@@ -433,6 +505,8 @@ export default function IssuesPage() {
|
||||
}
|
||||
}, [grouped]);
|
||||
|
||||
const isManager = userRole === 'MANAGER' || userRole === 'ADMIN';
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@@ -442,6 +516,20 @@ export default function IssuesPage() {
|
||||
<p className="text-text-muted text-sm mt-0.5">Track and manage field support requests</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
||||
{/* Approval mode toggle — only for MANAGER/ADMIN */}
|
||||
{isManager && (
|
||||
<button
|
||||
onClick={() => setApprovalMode(!approvalMode)}
|
||||
className={`flex items-center space-x-2 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors flex-1 sm:flex-none justify-center ${
|
||||
approvalMode
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-white border border-border text-text-main hover:bg-surface-container'
|
||||
}`}
|
||||
>
|
||||
<ListChecks className="w-4 h-4" />
|
||||
<span>{approvalMode ? 'All Tickets' : 'Pending Approvals'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity flex-1 sm:flex-none justify-center"
|
||||
@@ -477,80 +565,120 @@ export default function IssuesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64 text-text-muted text-sm">Loading tickets...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: column selector */}
|
||||
<div className="md:hidden mb-4">
|
||||
<select
|
||||
value={mobileColumn}
|
||||
onChange={(e) => setMobileColumn(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-white border border-border text-sm"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<option key={col.key} value={col.key}>
|
||||
{col.label} ({grouped[col.key]?.length || 0})
|
||||
</option>
|
||||
{/* Approval Mode View */}
|
||||
{approvalMode ? (
|
||||
<div>
|
||||
<h3 className="font-outfit text-lg font-semibold text-text-main mb-4">Pending Approvals ({pendingTickets.length})</h3>
|
||||
{pendingLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-text-muted text-sm">Loading pending approvals...</div>
|
||||
) : pendingTickets.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 text-text-muted text-sm bg-gray-50/30 rounded-2xl">
|
||||
No pending approvals
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pendingTickets.map((ticket) => (
|
||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||
<div className="bg-white border border-border rounded-xl p-4 hover:bg-surface-container-low transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-text-muted">#{ticket.ticketId}</span>
|
||||
<span className="font-medium text-text-main">{ticket.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">{ticket.requesterName}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
ticket.priority === 'CRITICAL' ? 'bg-status-critical-bg text-status-critical' :
|
||||
ticket.priority === 'HIGH' ? 'bg-amber-50 text-amber-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{ticket.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Kanban View */
|
||||
loading ? (
|
||||
<div className="flex items-center justify-center h-64 text-text-muted text-sm">Loading tickets...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: column selector */}
|
||||
<div className="md:hidden mb-4">
|
||||
<select
|
||||
value={mobileColumn}
|
||||
onChange={(e) => setMobileColumn(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-white border border-border text-sm"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<option key={col.key} value={col.key}>
|
||||
{col.label} ({grouped[col.key]?.length || 0})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: all columns */}
|
||||
<div className="hidden md:flex gap-4 overflow-x-auto pb-2">
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="flex-1 min-w-[220px]">
|
||||
<KanbanColumn title={col.label} count={grouped[col.key].length} color={col.color}>
|
||||
{grouped[col.key].map((ticket) => (
|
||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||
<TicketCard
|
||||
ticket={{
|
||||
ticketId: ticket.ticketId,
|
||||
title: ticket.title,
|
||||
projectName: ticket.projectName,
|
||||
requesterName: ticket.requesterName,
|
||||
assigneeName: ticket.assigneeName,
|
||||
priority: ticket.priority,
|
||||
category: ticket.category,
|
||||
status: ticket.status,
|
||||
dueDate: ticket.dueDate,
|
||||
isImminent: ticket.isImminent,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</KanbanColumn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Desktop: all columns */}
|
||||
<div className="hidden md:flex gap-4 overflow-x-auto pb-2">
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="flex-1 min-w-[220px]">
|
||||
<KanbanColumn title={col.label} count={grouped[col.key].length} color={col.color}>
|
||||
{grouped[col.key].map((ticket) => (
|
||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||
<TicketCard
|
||||
ticket={{
|
||||
ticketId: ticket.ticketId,
|
||||
title: ticket.title,
|
||||
projectName: ticket.projectName,
|
||||
requesterName: ticket.requesterName,
|
||||
assigneeName: ticket.assigneeName,
|
||||
priority: ticket.priority,
|
||||
category: ticket.category,
|
||||
status: ticket.status,
|
||||
dueDate: ticket.dueDate,
|
||||
isImminent: ticket.isImminent,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</KanbanColumn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: single column */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{(grouped[mobileColumn] || []).map((ticket) => (
|
||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||
<TicketCard
|
||||
ticket={{
|
||||
ticketId: ticket.ticketId,
|
||||
title: ticket.title,
|
||||
projectName: ticket.projectName,
|
||||
requesterName: ticket.requesterName,
|
||||
assigneeName: ticket.assigneeName,
|
||||
priority: ticket.priority,
|
||||
category: ticket.category,
|
||||
status: ticket.status,
|
||||
dueDate: ticket.dueDate,
|
||||
isImminent: ticket.isImminent,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{(grouped[mobileColumn] || []).length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-text-muted text-sm bg-gray-50/30 rounded-2xl">
|
||||
No tickets in this column
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{/* Mobile: single column */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{(grouped[mobileColumn] || []).map((ticket) => (
|
||||
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
|
||||
<TicketCard
|
||||
ticket={{
|
||||
ticketId: ticket.ticketId,
|
||||
title: ticket.title,
|
||||
projectName: ticket.projectName,
|
||||
requesterName: ticket.requesterName,
|
||||
assigneeName: ticket.assigneeName,
|
||||
priority: ticket.priority,
|
||||
category: ticket.category,
|
||||
status: ticket.status,
|
||||
dueDate: ticket.dueDate,
|
||||
isImminent: ticket.isImminent,
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
{(grouped[mobileColumn] || []).length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-text-muted text-sm bg-gray-50/30 rounded-2xl">
|
||||
No tickets in this column
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ interface EditForm {
|
||||
education: string;
|
||||
schoolName: string;
|
||||
position: string;
|
||||
managerId: string;
|
||||
backupManagerId: string;
|
||||
major: string;
|
||||
graduationDate: string;
|
||||
residence: string;
|
||||
@@ -97,7 +99,7 @@ export default function AccountsPage() {
|
||||
const [createForm, setCreateForm] = useState<CreateForm>({ ...emptyCreateForm });
|
||||
const [editForm, setEditForm] = useState<EditForm>({
|
||||
name: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
|
||||
joinedDate: '', leftDate: '', birthDate: '', education: '', schoolName: '', position: '', major: '', graduationDate: '', residence: '',
|
||||
joinedDate: '', leftDate: '', birthDate: '', education: '', schoolName: '', position: '', managerId: '', backupManagerId: '', major: '', graduationDate: '', residence: '',
|
||||
});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
@@ -177,6 +179,8 @@ export default function AccountsPage() {
|
||||
education: emp.education || '',
|
||||
schoolName: emp.schoolName || '',
|
||||
position: emp.position || '',
|
||||
managerId: String(emp.managerId ?? ''),
|
||||
backupManagerId: String(emp.backupManagerId ?? ''),
|
||||
major: emp.major || '',
|
||||
graduationDate: emp.graduationDate || '',
|
||||
residence: emp.residence || '',
|
||||
@@ -191,7 +195,10 @@ export default function AccountsPage() {
|
||||
setIsSubmitting(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
await employeeService.update(targetEmployee.empId, editForm);
|
||||
const payload: Record<string, unknown> = { ...editForm };
|
||||
payload.managerId = editForm.managerId ? parseInt(editForm.managerId) : null;
|
||||
payload.backupManagerId = editForm.backupManagerId ? parseInt(editForm.backupManagerId) : null;
|
||||
await employeeService.update(targetEmployee.empId, payload as Partial<Employee>);
|
||||
setShowEditModal(false);
|
||||
await fetchEmployees();
|
||||
} catch (err: any) {
|
||||
@@ -601,6 +608,24 @@ export default function AccountsPage() {
|
||||
<option value="DIRECTOR">이사</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>사수 (직속 상사)</label>
|
||||
<select value={editForm.managerId} onChange={(e) => setEditForm((f) => ({ ...f, managerId: e.target.value }))} className={selectClass}>
|
||||
<option value="">선택 안 함</option>
|
||||
{employees.map((e) => (
|
||||
<option key={e.empId} value={e.empId}>{e.name} ({e.roleCode})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>부사수 (대리 결재)</label>
|
||||
<select value={editForm.backupManagerId} onChange={(e) => setEditForm((f) => ({ ...f, backupManagerId: e.target.value }))} className={selectClass}>
|
||||
<option value="">선택 안 함</option>
|
||||
{employees.map((e) => (
|
||||
<option key={e.empId} value={e.empId}>{e.name} ({e.roleCode})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>학과</label>
|
||||
<input value={editForm.major} onChange={(e) => setEditForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
|
||||
|
||||
Reference in New Issue
Block a user