Files
cloud-host/frontend/src/app/[lang]/dashboard/tickets/page.tsx
T
keyhan 91a66d5645 Restyle toasts and centralize friendly error handling.
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>
2026-06-13 11:45:26 +03:30

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>
);
}