91a66d5645
Replace the default react-toastify look with project-styled toast cards (icon chip, rounded shell, RTL-aware container, type-colored progress bar) via a new notify helper and globals.css overrides. Add a central error layer (src/lib/errors.ts): classify any caught error by HTTP status / network condition, log the full technical detail (including the raw backend message) to the console only, and surface a friendly, localized message to the user. Raw backend messages are no longer shown. All ~190 toast call sites across 22 files move to notify, routing backend errors through notify.error(err, fallback); dead apiErrorMessage/formatApiError helpers removed. Adds an `errors` section to the fa/en dictionaries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
198 lines
8.1 KiB
TypeScript
198 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Link } from '@/i18n/Link';
|
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
|
import api from '@/lib/api';
|
|
import { useAuthStore } from '@/lib/store';
|
|
import { notify } from '@/lib/notify';
|
|
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
|
|
import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react';
|
|
import { Select } from '@/components/ui/select';
|
|
|
|
const statusColors: Record<string, string> = {
|
|
open: 'bg-yellow-100 text-yellow-700',
|
|
waiting: 'bg-orange-100 text-orange-700',
|
|
answered: 'bg-green-100 text-green-700',
|
|
closed: 'bg-gray-100 text-gray-500',
|
|
};
|
|
|
|
const priorityColors: Record<string, string> = {
|
|
low: 'bg-blue-100 text-blue-700',
|
|
medium: 'bg-yellow-100 text-yellow-700',
|
|
high: 'bg-red-100 text-red-700',
|
|
};
|
|
|
|
export default function TicketsPage() {
|
|
const t = useT();
|
|
const tk = t.dashboard.tickets;
|
|
const locale = useLocale();
|
|
const queryClient = useQueryClient();
|
|
const user = useAuthStore((s) => s.user);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
|
|
// Staff can only create tickets to other departments
|
|
const availableDepartments: { value: TicketDepartment; label: string }[] = [];
|
|
if (user?.role !== 'technical') {
|
|
availableDepartments.push({ value: 'technical', label: tk.departmentFull.technical });
|
|
}
|
|
if (user?.role !== 'sales') {
|
|
availableDepartments.push({ value: 'sales', label: tk.departmentFull.sales });
|
|
}
|
|
const canCreateTicket = availableDepartments.length > 0;
|
|
const [form, setForm] = useState<CreateTicketDto>({
|
|
subject: '',
|
|
department: availableDepartments[0]?.value || 'technical',
|
|
priority: 'medium',
|
|
message: '',
|
|
});
|
|
|
|
const { data: tickets = [], isLoading } = useQuery<Ticket[]>({
|
|
queryKey: ['my-tickets'],
|
|
queryFn: () => api.get('/tickets/my').then((r) => r.data),
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: CreateTicketDto) => api.post('/tickets', data).then((r) => r.data),
|
|
onSuccess: () => {
|
|
notify.success(tk.createdSuccess);
|
|
queryClient.invalidateQueries({ queryKey: ['my-tickets'] });
|
|
setShowCreate(false);
|
|
setForm({ subject: '', department: availableDepartments[0]?.value || 'technical', priority: 'medium', message: '' });
|
|
},
|
|
onError: (err: any) => notify.error(err, tk.createFailed),
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
createMutation.mutate(form);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">{tk.myTickets}</h1>
|
|
<p className="text-sm text-gray-500 mt-1">{tk.myTicketsSubtitle}</p>
|
|
</div>
|
|
{canCreateTicket && (
|
|
<button onClick={() => setShowCreate(!showCreate)} className="btn-primary">
|
|
{showCreate ? <><X className="w-4 h-4 inline" /> {t.common.cancel}</> : `+ ${tk.newTicket}`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create Ticket Form */}
|
|
{showCreate && canCreateTicket && (
|
|
<form onSubmit={handleSubmit} className="card p-6 space-y-4">
|
|
<h2 className="text-lg font-semibold text-gray-900">{tk.createNewTicket}</h2>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.subject}</label>
|
|
<input
|
|
type="text"
|
|
className="input-field"
|
|
placeholder={tk.subjectPlaceholder}
|
|
value={form.subject}
|
|
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
|
required
|
|
minLength={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.departmentField}</label>
|
|
<Select
|
|
size="md"
|
|
ariaLabel={tk.departmentField}
|
|
value={form.department}
|
|
onChange={(v) => setForm({ ...form, department: v as TicketDepartment })}
|
|
options={availableDepartments.map((dept) => ({ value: dept.value, label: dept.label }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.priorityField}</label>
|
|
<Select
|
|
size="md"
|
|
ariaLabel={tk.priorityField}
|
|
value={form.priority || 'low'}
|
|
onChange={(v) => setForm({ ...form, priority: v as TicketPriority })}
|
|
options={[
|
|
{ value: 'low', label: tk.priority.low },
|
|
{ value: 'medium', label: tk.priority.medium },
|
|
{ value: 'high', label: tk.priority.high },
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.messageField}</label>
|
|
<textarea
|
|
className="input-field min-h-[120px]"
|
|
placeholder={tk.messagePlaceholder}
|
|
value={form.message}
|
|
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
|
required
|
|
minLength={10}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<button type="submit" className="btn-primary" disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? tk.creating : tk.submitTicket}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Tickets List */}
|
|
{isLoading ? (
|
|
<div className="text-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary-600 border-t-transparent mx-auto" />
|
|
</div>
|
|
) : tickets.length === 0 ? (
|
|
<div className="card p-12 text-center">
|
|
<TicketIcon className="w-10 h-10 mx-auto text-gray-300" />
|
|
<h3 className="mt-3 text-lg font-semibold text-gray-700">{tk.noTickets}</h3>
|
|
<p className="text-sm text-gray-500 mt-1">{tk.noTicketsHint}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{tickets.map((ticket) => (
|
|
<Link
|
|
key={ticket.id}
|
|
href={`/dashboard/tickets/${ticket.id}`}
|
|
className="card p-4 block hover:shadow-md transition-shadow"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h3 className="font-semibold text-gray-900 truncate">{ticket.subject}</h3>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[ticket.status]}`}>
|
|
{(tk.status as Record<string, string>)[ticket.status] ?? ticket.status}
|
|
</span>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityColors[ticket.priority]}`}>
|
|
{(tk.priority as Record<string, string>)[ticket.priority] ?? ticket.priority}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
|
<span className="flex items-center gap-1">{ticket.department === 'technical' ? <><Wrench className="w-3 h-3" /> {tk.department.technical}</> : <><Briefcase className="w-3 h-3" /> {tk.department.sales}</>}</span>
|
|
<span>•</span>
|
|
<span>{new Date(ticket.createdAt).toLocaleDateString(locale, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
|
<span>•</span>
|
|
<span>{tk.messages.replace('{n}', String(ticket.messages?.length || 0))}</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-gray-400 text-sm rtl:rotate-180">→</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|