Files
SR-SYSTEM/src/app/(main)/issues/page.tsx
T
2026-06-08 17:49:39 +09:00

691 lines
30 KiB
TypeScript

'use client';
import React, { useState, useEffect, useMemo } from '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';
import { KanbanColumn } from '@/components/issues/KanbanBoard';
import ticketService from '@/services/ticketService';
import apiClient from '@/services/apiClient';
import type { IssueTicket, TicketCategory, TicketPriority, Project } from '@/types';
import { useCommonCodes } from '@/hooks/useCommonCodes';
const columns = [
{ key: 'OPEN', label: 'Open', color: 'bg-status-open-text' },
{ key: 'ASSIGNED', label: 'Assigned', color: 'bg-status-pending-text' },
{ key: 'IN_PROGRESS', label: 'In Progress', color: 'bg-status-pending-text' },
{ key: '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' },
];
function CreateTicketModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [formData, setFormData] = useState({
title: '',
description: '',
category: 'BUG',
priority: 'NORMAL',
projectId: '',
});
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>>({});
useEffect(() => {
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) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (fieldErrors[field]) {
setFieldErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const updateExtraField = (field: string, value: string) => {
setExtraFields((prev) => ({ ...prev, [field]: value }));
if (fieldErrors[field]) {
setFieldErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const needsApprover = ['VACATION', 'EXPENSE', 'EQUIPMENT_REQUEST', 'GENERAL'].includes(formData.category);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errors: Record<string, string> = {};
if (!formData.title.trim()) errors.title = '제목을 입력해주세요';
if (!formData.description.trim()) errors.description = '내용을 입력해주세요';
if (formData.category === 'VACATION') {
if (!extraFields.startDate) errors.startDate = '시작일을 선택해주세요';
if (!extraFields.endDate) errors.endDate = '종료일을 선택해주세요';
}
if (formData.category === 'EXPENSE') {
if (!extraFields.amount) errors.amount = '금액을 입력해주세요';
}
if (formData.category === 'EQUIPMENT_REQUEST') {
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);
return;
}
setSubmitting(true);
try {
let finalDescription = formData.description;
const extras: string[] = [];
if (formData.category === 'VACATION') {
extras.push(`\n\n[휴가 신청]\n시작일: ${extraFields.startDate}\n종료일: ${extraFields.endDate}`);
}
if (formData.category === 'EXPENSE') {
extras.push(`\n\n[비용 청구]\n금액: ${extraFields.amount}원${extraFields.receiptUrl ? `\n영수증: ${extraFields.receiptUrl}` : ''}`);
}
if (formData.category === 'EQUIPMENT_REQUEST') {
extras.push(`\n\n[장비 요청]\n장비 유형: ${extraFields.equipmentType}\n긴급도: ${extraFields.urgency}`);
}
if (extras.length > 0) {
finalDescription += extras.join('');
}
await ticketService.createTicket({
title: formData.title,
description: finalDescription,
category: formData.category as TicketCategory,
priority: formData.priority as TicketPriority,
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();
} catch (err: any) {
setFieldErrors({ submit: err.message || '티켓 생성에 실패했습니다' });
} finally {
setSubmitting(false);
}
};
const renderFieldError = (field: string) => {
if (!fieldErrors[field]) return null;
return <p className="mt-1 text-xs text-error">{fieldErrors[field]}</p>;
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
<div className="flex justify-between items-center p-6 border-b border-border">
<h3 className="font-outfit text-xl font-semibold text-text-main">New SR Ticket</h3>
<button onClick={onClose} className="p-2 hover:bg-surface-container rounded-lg transition-colors">
<X className="w-5 h-5 text-text-muted" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{fieldErrors.submit && (
<div className="p-3 bg-error-container/20 border border-error text-error rounded-lg text-sm">
{fieldErrors.submit}
</div>
)}
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
autoFocus
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.title ? 'border-error' : 'border-border'
}`}
placeholder="Brief description of the issue"
/>
{renderFieldError('title')}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Category</label>
<select
value={formData.category}
onChange={(e) => {
updateField('category', e.target.value);
setExtraFields({});
setFieldErrors((prev) => {
const next = { ...prev };
delete next.startDate;
delete next.endDate;
delete next.amount;
delete next.equipmentType;
delete next.urgency;
delete next.approverId;
delete next.backupApproverId;
return next;
});
}}
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white"
>
<option value="BUG">Bug</option>
<option value="FEATURE">Feature</option>
<option value="SUPPORT">Support</option>
<option value="QA">QA</option>
<option value="GENERAL">일반 문의</option>
<option value="VACATION">휴가 신청</option>
<option value="EXPENSE">비용 청구</option>
<option value="EQUIPMENT_REQUEST">장비 요청</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Priority</label>
<select
value={formData.priority}
onChange={(e) => updateField('priority', e.target.value)}
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white"
>
<option value="LOW">Low</option>
<option value="NORMAL">Normal</option>
<option value="HIGH">High</option>
<option value="CRITICAL">Critical</option>
</select>
</div>
</div>
{/* Project: only for project-related categories */}
{['BUG', 'FEATURE', 'SUPPORT', 'QA'].includes(formData.category) && (
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">Project</label>
<select
value={formData.projectId}
onChange={(e) => updateField('projectId', e.target.value)}
className="w-full px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white"
>
<option value="">해당 없음</option>
{projects.map((p) => (
<option key={p.projectId} value={p.projectId}>
{p.projectName}
</option>
))}
</select>
</div>
)}
{formData.category === 'VACATION' && (
<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>
<input
type="date"
value={extraFields.startDate || ''}
onChange={(e) => updateExtraField('startDate', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.startDate ? 'border-error' : 'border-border'
}`}
/>
{renderFieldError('startDate')}
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
종료일 <span className="text-red-500">*</span>
</label>
<input
type="date"
value={extraFields.endDate || ''}
onChange={(e) => updateExtraField('endDate', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.endDate ? 'border-error' : 'border-border'
}`}
/>
{renderFieldError('endDate')}
</div>
</div>
)}
{formData.category === 'EXPENSE' && (
<>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
금액 (원) <span className="text-red-500">*</span>
</label>
<input
type="number"
value={extraFields.amount || ''}
onChange={(e) => updateExtraField('amount', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.amount ? 'border-error' : 'border-border'
}`}
placeholder="0"
/>
{renderFieldError('amount')}
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">영수증</label>
<input type="file" accept="image/*,.pdf" onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
updateExtraField('receiptName', file.name);
try {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('accessToken');
const res = await fetch('/api/v1/files/upload', {
method: 'POST', headers: token ? {Authorization: `Bearer ${token}`} : {}, body: form,
});
const json = await res.json();
if (json.success) updateExtraField('receiptUrl', json.data);
} catch { updateExtraField('receiptUrl', ''); }
}} className="w-full text-sm" />
{extraFields.receiptName && (
<p className="mt-1 text-xs text-text-muted">📎 {extraFields.receiptName}</p>
)}
</div>
</>
)}
{formData.category === 'EQUIPMENT_REQUEST' && (
<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>
<input
type="text"
value={extraFields.equipmentType || ''}
onChange={(e) => updateExtraField('equipmentType', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary ${
fieldErrors.equipmentType ? 'border-error' : 'border-border'
}`}
placeholder="예: 노트북, 모니터"
/>
{renderFieldError('equipmentType')}
</div>
<div>
<label className="block text-sm font-medium text-text-main mb-1.5">
긴급도 <span className="text-red-500">*</span>
</label>
<select
value={extraFields.urgency || ''}
onChange={(e) => updateExtraField('urgency', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white ${
fieldErrors.urgency ? 'border-error' : 'border-border'
}`}
>
<option value="">선택</option>
<option value="LOW">낮음</option>
<option value="NORMAL">보통</option>
<option value="HIGH">높음</option>
</select>
{renderFieldError('urgency')}
</div>
</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>
</label>
<textarea
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
className={`w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none ${
fieldErrors.description ? 'border-error' : 'border-border'
}`}
placeholder="Detailed explanation, error logs, steps to reproduce..."
rows={4}
/>
{renderFieldError('description')}
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 text-sm font-medium text-text-main bg-surface-container hover:bg-surface-container-high rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2.5 text-sm font-medium text-white bg-primary hover:opacity-90 rounded-lg transition-opacity disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Ticket'}
</button>
</div>
</form>
</div>
</div>
);
}
export default function IssuesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [tickets, setTickets] = useState<IssueTicket[]>([]);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useState('');
const [filterCategory, setFilterCategory] = useState('ALL');
const [filterPriority, setFilterPriority] = useState('ALL');
const [showCreateModal, setShowCreateModal] = useState(false);
const [mobileColumn, setMobileColumn] = useState<string>(columns[0].key);
const [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');
useEffect(() => {
const q = searchParams.get('search');
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);
}).catch(console.error).finally(() => setLoading(false));
}, []);
const refreshTickets = () => {
setLoading(true);
ticketService.getTickets({ size: 100 }).then((res) => {
setTickets(res.data.content);
}).catch(console.error).finally(() => setLoading(false));
};
const filtered = useMemo(() => {
return tickets.filter((t) => {
if (searchKeyword && !t.title.toLowerCase().includes(searchKeyword.toLowerCase())) return false;
if (filterCategory !== 'ALL' && t.category !== filterCategory) return false;
if (filterPriority !== 'ALL' && t.priority !== filterPriority) return false;
return true;
});
}, [tickets, searchKeyword, filterCategory, filterPriority]);
const grouped = useMemo(() => {
const map: Record<string, IssueTicket[]> = {};
columns.forEach((c) => { map[c.key] = []; });
filtered.forEach((t) => {
const colKey = t.status === 'RESOLVED' || t.status === 'CLOSED' ? 'DONE' : t.status;
if (map[colKey]) map[colKey].push(t);
});
return map;
}, [filtered]);
useEffect(() => {
const firstWithTickets = columns.find((col) => grouped[col.key]?.length > 0);
if (firstWithTickets) {
setMobileColumn(firstWithTickets.key);
}
}, [grouped]);
const isManager = userRole === 'MANAGER' || userRole === 'ADMIN';
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6">
<div>
<h2 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">SR Issues</h2>
<p className="text-text-muted text-sm mt-0.5">Track and manage field support requests</p>
</div>
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
{/* 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"
>
<Plus className="w-4 h-4" />
<span>New Ticket</span>
</button>
<select value={filterPriority} onChange={(e) => setFilterPriority(e.target.value)} className="text-xs bg-white border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary">
<option value="ALL">All Priority</option>
{priorities.map((p) => (
<option key={p.codeValue} value={p.codeValue}>{p.codeName}</option>
))}
</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="text-xs bg-white border border-border rounded-lg px-3 py-2 text-text-main focus:outline-none focus:ring-2 focus:ring-primary">
<option value="ALL">All Category</option>
{categories.map((c) => (
<option key={c.codeValue} value={c.codeValue}>{c.codeName}</option>
))}
</select>
</div>
</div>
<div className="relative w-full mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
value={searchKeyword}
onChange={(e) => {
setSearchKeyword(e.target.value);
router.replace(`/issues?search=${encodeURIComponent(e.target.value)}`);
}}
placeholder="Search tickets by title..."
className="w-full sm:w-96 pl-10 pr-4 py-2.5 rounded-xl bg-white border border-border text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
/>
</div>
{/* 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>
))}
</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>
{/* Mobile: single column */}
<div className="md:hidden space-y-3">
{(grouped[mobileColumn] || []).map((ticket) => (
<Link key={ticket.ticketId} href={`/issues/${ticket.ticketId}`}>
<TicketCard
ticket={{
ticketId: ticket.ticketId,
title: ticket.title,
projectName: ticket.projectName,
requesterName: ticket.requesterName,
assigneeName: ticket.assigneeName,
priority: ticket.priority,
category: ticket.category,
status: ticket.status,
dueDate: ticket.dueDate,
isImminent: ticket.isImminent,
}}
/>
</Link>
))}
{(grouped[mobileColumn] || []).length === 0 && (
<div className="flex items-center justify-center h-32 text-text-muted text-sm bg-gray-50/30 rounded-2xl">
No tickets in this column
</div>
)}
</div>
</>
)
)}
</div>
{showCreateModal && (
<CreateTicketModal onClose={() => setShowCreateModal(false)} onSuccess={refreshTickets} />
)}
</div>
);
}