@devpablocristo/modules-scheduling 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,650 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { confirmAction } from '@devpablocristo/core-browser';
4
+ import type { SchedulingClient } from './client';
5
+ import { resolveSchedulingCopyLocale } from './locale';
6
+ import type {
7
+ Branch,
8
+ DayAgendaItem,
9
+ Queue,
10
+ QueueOperatorBoardCopy,
11
+ QueueStatus,
12
+ QueueTicketStatus,
13
+ } from './types';
14
+
15
+ type QueueBoardTicket = {
16
+ id: string;
17
+ queueId: string;
18
+ label: string;
19
+ status: QueueTicketStatus;
20
+ number?: number;
21
+ };
22
+
23
+ type TicketDraft = {
24
+ customerName: string;
25
+ customerPhone: string;
26
+ customerEmail: string;
27
+ priority: number;
28
+ };
29
+
30
+ const queueKeys = {
31
+ branches: ['scheduling', 'branches'] as const,
32
+ queues: (branchId: string | null) => ['scheduling', 'queues', branchId ?? 'all'] as const,
33
+ day: (branchId: string | null, date: string) => ['scheduling', 'day', branchId ?? 'all', date] as const,
34
+ };
35
+
36
+ export const queueOperatorBoardCopyPresets: Record<'en' | 'es', QueueOperatorBoardCopy> = {
37
+ en: {
38
+ title: 'Virtual queues',
39
+ description: 'Manage live queues from the same workspace.',
40
+ branchLabel: 'Branch',
41
+ dateLabel: 'Date',
42
+ loading: 'Loading queues…',
43
+ noQueues: 'No queues configured for this branch.',
44
+ issueTicketTitle: 'Create ticket',
45
+ issueTicketDescription: 'Create a new reception or walk-in ticket.',
46
+ customerNameLabel: 'Customer name',
47
+ customerPhoneLabel: 'Phone',
48
+ customerEmailLabel: 'Email',
49
+ priorityLabel: 'Priority',
50
+ issueTicket: 'Create ticket',
51
+ issuingTicket: 'Issuing…',
52
+ callNext: 'Call next',
53
+ pauseQueue: 'Pause queue',
54
+ reopenQueue: 'Reopen queue',
55
+ closeQueue: 'Close queue',
56
+ waitingColumn: 'Waiting',
57
+ activeColumn: 'Active',
58
+ finishedColumn: 'Finished',
59
+ noTickets: 'No tickets in this column.',
60
+ requestedAtLabel: 'Requested',
61
+ statusLabel: 'Status',
62
+ serveTicket: 'Serve',
63
+ completeTicket: 'Complete',
64
+ noShowTicket: 'No-show',
65
+ cancelTicket: 'Cancel',
66
+ returnToWaiting: 'Send back to waiting',
67
+ nextTicketTitle: 'Next in line',
68
+ queueMetricsIssued: 'Issued',
69
+ queueMetricsWaiting: 'Waiting',
70
+ queueMetricsServing: 'Serving',
71
+ queueMetricsDone: 'Done',
72
+ confirmDangerTitle: 'Confirm action',
73
+ dismissConfirm: 'Cancel',
74
+ confirmCancelDescription: 'This ticket will be removed from the active queue.',
75
+ confirmNoShowDescription: 'This ticket will be marked as no-show.',
76
+ closeQueueDescription: 'This queue will stop receiving new customers until you reopen it.',
77
+ statuses: {
78
+ active: 'Active',
79
+ paused: 'Paused',
80
+ closed: 'Closed',
81
+ waiting: 'Waiting',
82
+ called: 'Called',
83
+ serving: 'Serving',
84
+ completed: 'Completed',
85
+ no_show: 'No-show',
86
+ cancelled: 'Cancelled',
87
+ },
88
+ },
89
+ es: {
90
+ title: 'Colas virtuales',
91
+ description: 'Operá las colas activas desde el mismo espacio de agenda.',
92
+ branchLabel: 'Sucursal',
93
+ dateLabel: 'Fecha',
94
+ loading: 'Cargando colas…',
95
+ noQueues: 'No hay colas configuradas para esta sucursal.',
96
+ issueTicketTitle: 'Emitir ticket',
97
+ issueTicketDescription: 'Crear un ticket nuevo de recepción o walk-in.',
98
+ customerNameLabel: 'Cliente',
99
+ customerPhoneLabel: 'Teléfono',
100
+ customerEmailLabel: 'Email',
101
+ priorityLabel: 'Prioridad',
102
+ issueTicket: 'Emitir ticket',
103
+ issuingTicket: 'Emitiendo…',
104
+ callNext: 'Llamar siguiente',
105
+ pauseQueue: 'Pausar cola',
106
+ reopenQueue: 'Reabrir cola',
107
+ closeQueue: 'Cerrar cola',
108
+ waitingColumn: 'Esperando',
109
+ activeColumn: 'En curso',
110
+ finishedColumn: 'Finalizados',
111
+ noTickets: 'No hay tickets en esta columna.',
112
+ requestedAtLabel: 'Solicitado',
113
+ statusLabel: 'Estado',
114
+ serveTicket: 'Atender',
115
+ completeTicket: 'Completar',
116
+ noShowTicket: 'No-show',
117
+ cancelTicket: 'Cancelar',
118
+ returnToWaiting: 'Volver',
119
+ nextTicketTitle: 'Carril de atención',
120
+ queueMetricsIssued: 'Emitidos',
121
+ queueMetricsWaiting: 'Esperando',
122
+ queueMetricsServing: 'Atendiendo',
123
+ queueMetricsDone: 'Resueltos',
124
+ confirmDangerTitle: 'Confirmar acción',
125
+ dismissConfirm: 'Cancelar',
126
+ confirmCancelDescription: 'Este ticket saldrá de la cola activa.',
127
+ confirmNoShowDescription: 'Este ticket se marcará como no-show.',
128
+ closeQueueDescription: 'La cola dejará de recibir más flujo hasta que la reabras.',
129
+ statuses: {
130
+ active: 'Activa',
131
+ paused: 'Pausada',
132
+ closed: 'Cerrada',
133
+ waiting: 'Esperando',
134
+ called: 'Llamado',
135
+ serving: 'Atendiendo',
136
+ completed: 'Completado',
137
+ no_show: 'No-show',
138
+ cancelled: 'Cancelado',
139
+ },
140
+ },
141
+ };
142
+
143
+ export type QueueOperatorBoardProps = {
144
+ client: SchedulingClient;
145
+ locale?: string;
146
+ copy?: Partial<QueueOperatorBoardCopy>;
147
+ searchQuery?: string;
148
+ initialBranchId?: string;
149
+ initialDate?: string;
150
+ className?: string;
151
+ };
152
+
153
+ function toDateInputValue(value: Date): string {
154
+ return value.toISOString().slice(0, 10);
155
+ }
156
+
157
+ function matchesSearch(searchQuery: string, values: Array<string | undefined | null>): boolean {
158
+ if (!searchQuery.trim()) {
159
+ return true;
160
+ }
161
+ const normalized = searchQuery.trim().toLocaleLowerCase();
162
+ return values.some((value) => value?.toLocaleLowerCase().includes(normalized));
163
+ }
164
+
165
+ function parseQueueTicket(item: DayAgendaItem): QueueBoardTicket | null {
166
+ if (item.type !== 'queue_ticket') {
167
+ return null;
168
+ }
169
+ const queueId = typeof item.metadata?.queue_id === 'string' ? item.metadata.queue_id : '';
170
+ if (!queueId) {
171
+ return null;
172
+ }
173
+ return {
174
+ id: item.id,
175
+ queueId,
176
+ label: item.label,
177
+ status: item.status as QueueTicketStatus,
178
+ number: typeof item.metadata?.number === 'number' ? item.metadata.number : undefined,
179
+ };
180
+ }
181
+
182
+ function ticketActions(status: QueueTicketStatus): Array<'serve' | 'complete' | 'no_show' | 'cancel' | 'return'> {
183
+ switch (status) {
184
+ case 'waiting':
185
+ return ['serve', 'no_show', 'cancel'];
186
+ case 'called':
187
+ return ['serve', 'return', 'no_show', 'cancel'];
188
+ case 'serving':
189
+ return ['complete', 'return', 'no_show'];
190
+ default:
191
+ return [];
192
+ }
193
+ }
194
+
195
+ function statusTone(status: QueueStatus | QueueTicketStatus): string {
196
+ switch (status) {
197
+ case 'active':
198
+ case 'serving':
199
+ case 'completed':
200
+ return 'modules-scheduling__badge modules-scheduling__badge--success';
201
+ case 'waiting':
202
+ case 'called':
203
+ case 'paused':
204
+ return 'modules-scheduling__badge modules-scheduling__badge--attention';
205
+ case 'closed':
206
+ case 'cancelled':
207
+ case 'no_show':
208
+ return 'modules-scheduling__badge modules-scheduling__badge--critical';
209
+ default:
210
+ return 'modules-scheduling__badge modules-scheduling__badge--neutral';
211
+ }
212
+ }
213
+
214
+ export function QueueOperatorBoard({
215
+ client,
216
+ locale = 'en',
217
+ copy: copyOverrides,
218
+ searchQuery = '',
219
+ initialBranchId,
220
+ initialDate,
221
+ className = '',
222
+ }: QueueOperatorBoardProps) {
223
+ const copy = { ...queueOperatorBoardCopyPresets[resolveSchedulingCopyLocale(locale)], ...copyOverrides };
224
+ const queryClient = useQueryClient();
225
+ const [selectedBranchId, setSelectedBranchId] = useState<string | null>(initialBranchId ?? null);
226
+ const [selectedDate, setSelectedDate] = useState(initialDate ?? toDateInputValue(new Date()));
227
+ const [drafts, setDrafts] = useState<Record<string, TicketDraft>>({});
228
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
229
+
230
+ const branchesQuery = useQuery<Branch[]>({
231
+ queryKey: queueKeys.branches,
232
+ queryFn: () => client.listBranches(),
233
+ staleTime: 60_000,
234
+ });
235
+
236
+ const queuesQuery = useQuery<Queue[]>({
237
+ queryKey: queueKeys.queues(selectedBranchId),
238
+ queryFn: () => client.listQueues(selectedBranchId),
239
+ enabled: Boolean(selectedBranchId),
240
+ staleTime: 15_000,
241
+ });
242
+
243
+ const dayAgendaQuery = useQuery<DayAgendaItem[]>({
244
+ queryKey: queueKeys.day(selectedBranchId, selectedDate),
245
+ queryFn: () => client.getDayAgenda(selectedBranchId, selectedDate),
246
+ enabled: Boolean(selectedBranchId),
247
+ staleTime: 10_000,
248
+ });
249
+
250
+ useEffect(() => {
251
+ if (selectedBranchId) {
252
+ return;
253
+ }
254
+ const preferred = (branchesQuery.data ?? []).find((branch) => branch.active) ?? branchesQuery.data?.[0];
255
+ if (preferred) {
256
+ setSelectedBranchId(preferred.id);
257
+ }
258
+ }, [branchesQuery.data, selectedBranchId]);
259
+
260
+ const invalidateBoard = async () => {
261
+ await Promise.all([
262
+ queryClient.invalidateQueries({ queryKey: queueKeys.queues(selectedBranchId) }),
263
+ queryClient.invalidateQueries({ queryKey: queueKeys.day(selectedBranchId, selectedDate) }),
264
+ ]);
265
+ };
266
+
267
+ const issueMutation = useMutation({
268
+ mutationFn: async ({ queueId, draft }: { queueId: string; draft: TicketDraft }) =>
269
+ client.createQueueTicket(queueId, {
270
+ customer_name: draft.customerName.trim(),
271
+ customer_phone: draft.customerPhone.trim(),
272
+ customer_email: draft.customerEmail.trim() || undefined,
273
+ priority: draft.priority,
274
+ }),
275
+ onMutate: () => setErrorMessage(null),
276
+ onSuccess: async (_item, variables) => {
277
+ setDrafts((current) => ({
278
+ ...current,
279
+ [variables.queueId]: { customerName: '', customerPhone: '', customerEmail: '', priority: 0 },
280
+ }));
281
+ await invalidateBoard();
282
+ },
283
+ onError: (error: Error) => setErrorMessage(error.message),
284
+ });
285
+
286
+ const queueMutation = useMutation({
287
+ mutationFn: async ({ queueId, action }: { queueId: string; action: 'pause' | 'reopen' | 'close' | 'call-next' }) => {
288
+ switch (action) {
289
+ case 'pause':
290
+ return client.pauseQueue(queueId);
291
+ case 'reopen':
292
+ return client.reopenQueue(queueId);
293
+ case 'close':
294
+ return client.closeQueue(queueId);
295
+ case 'call-next':
296
+ return client.callNext(queueId);
297
+ }
298
+ },
299
+ onMutate: () => setErrorMessage(null),
300
+ onSuccess: async () => {
301
+ await invalidateBoard();
302
+ },
303
+ onError: (error: Error) => setErrorMessage(error.message),
304
+ });
305
+
306
+ const ticketMutation = useMutation({
307
+ mutationFn: async ({
308
+ queueId,
309
+ ticketId,
310
+ action,
311
+ }: {
312
+ queueId: string;
313
+ ticketId: string;
314
+ action: 'serve' | 'complete' | 'no_show' | 'cancel' | 'return';
315
+ }) => {
316
+ switch (action) {
317
+ case 'serve':
318
+ return client.serveTicket(queueId, ticketId);
319
+ case 'complete':
320
+ return client.completeTicket(queueId, ticketId);
321
+ case 'no_show':
322
+ return client.markTicketNoShow(queueId, ticketId);
323
+ case 'cancel':
324
+ return client.cancelTicket(queueId, ticketId);
325
+ case 'return':
326
+ return client.returnTicketToWaiting(queueId, ticketId);
327
+ }
328
+ },
329
+ onMutate: () => setErrorMessage(null),
330
+ onSuccess: async () => {
331
+ await invalidateBoard();
332
+ },
333
+ onError: (error: Error) => setErrorMessage(error.message),
334
+ });
335
+
336
+ const queues = useMemo(() => {
337
+ const allQueues = queuesQuery.data ?? [];
338
+ return allQueues.filter((queue) => matchesSearch(searchQuery, [queue.name, queue.code]));
339
+ }, [queuesQuery.data, searchQuery]);
340
+
341
+ const queueTickets = useMemo(() => {
342
+ const items = (dayAgendaQuery.data ?? [])
343
+ .map(parseQueueTicket)
344
+ .filter((item): item is QueueBoardTicket => item !== null);
345
+ return items.filter((item) => matchesSearch(searchQuery, [item.label, String(item.number ?? '')]));
346
+ }, [dayAgendaQuery.data, searchQuery]);
347
+
348
+ const groupedTickets = useMemo(() => {
349
+ const map = new Map<string, QueueBoardTicket[]>();
350
+ for (const ticket of queueTickets) {
351
+ const current = map.get(ticket.queueId) ?? [];
352
+ current.push(ticket);
353
+ map.set(ticket.queueId, current);
354
+ }
355
+ return map;
356
+ }, [queueTickets]);
357
+
358
+ const handleQueueAction = async (queue: Queue, action: 'pause' | 'reopen' | 'close' | 'call-next') => {
359
+ if (action === 'close') {
360
+ const confirmed = await confirmAction({
361
+ title: copy.confirmDangerTitle,
362
+ description: copy.closeQueueDescription,
363
+ confirmLabel: copy.closeQueue,
364
+ cancelLabel: copy.dismissConfirm,
365
+ tone: 'danger',
366
+ });
367
+ if (!confirmed) {
368
+ return;
369
+ }
370
+ }
371
+ await queueMutation.mutateAsync({ queueId: queue.id, action });
372
+ };
373
+
374
+ const handleTicketAction = async (
375
+ queueId: string,
376
+ ticketId: string,
377
+ action: 'serve' | 'complete' | 'no_show' | 'cancel' | 'return',
378
+ ) => {
379
+ if (action === 'cancel' || action === 'no_show') {
380
+ const confirmed = await confirmAction({
381
+ title: copy.confirmDangerTitle,
382
+ description: action === 'cancel' ? copy.confirmCancelDescription : copy.confirmNoShowDescription,
383
+ confirmLabel: action === 'cancel' ? copy.cancelTicket : copy.noShowTicket,
384
+ cancelLabel: copy.dismissConfirm,
385
+ tone: 'danger',
386
+ });
387
+ if (!confirmed) {
388
+ return;
389
+ }
390
+ }
391
+ await ticketMutation.mutateAsync({ queueId, ticketId, action });
392
+ };
393
+
394
+ const loading = branchesQuery.isLoading || queuesQuery.isLoading || dayAgendaQuery.isLoading;
395
+
396
+ return (
397
+ <section className={`modules-scheduling ${className}`.trim()}>
398
+ {errorMessage ? <div className="alert alert-error">{errorMessage}</div> : null}
399
+
400
+ <div className="card">
401
+ <div className="card-header">
402
+ <div>
403
+ <h2>{copy.title}</h2>
404
+ <p className="text-secondary">{copy.description}</p>
405
+ </div>
406
+ </div>
407
+ <div className="modules-scheduling__filters">
408
+ <div className="form-group grow">
409
+ <label htmlFor="queue-board-branch">{copy.branchLabel}</label>
410
+ <select
411
+ id="queue-board-branch"
412
+ value={selectedBranchId ?? ''}
413
+ onChange={(event) => setSelectedBranchId(event.target.value || null)}
414
+ >
415
+ {(branchesQuery.data ?? []).map((branch) => (
416
+ <option key={branch.id} value={branch.id}>
417
+ {branch.name}
418
+ </option>
419
+ ))}
420
+ </select>
421
+ </div>
422
+ <div className="form-group">
423
+ <label htmlFor="queue-board-date">{copy.dateLabel}</label>
424
+ <input
425
+ id="queue-board-date"
426
+ type="date"
427
+ value={selectedDate}
428
+ onChange={(event) => setSelectedDate(event.target.value)}
429
+ />
430
+ </div>
431
+ </div>
432
+ </div>
433
+
434
+ {loading ? (
435
+ <div className="card modules-scheduling__empty">
436
+ <div className="spinner" />
437
+ <p>{copy.loading}</p>
438
+ </div>
439
+ ) : queues.length === 0 ? (
440
+ <div className="card empty-state">
441
+ <p>{copy.noQueues}</p>
442
+ </div>
443
+ ) : (
444
+ <div className="modules-scheduling__queue-grid">
445
+ {queues.map((queue) => {
446
+ const items = groupedTickets.get(queue.id) ?? [];
447
+ const waiting = items.filter((item) => item.status === 'waiting');
448
+ const active = items.filter((item) => item.status === 'called' || item.status === 'serving');
449
+ const finished = items.filter(
450
+ (item) => item.status === 'completed' || item.status === 'cancelled' || item.status === 'no_show',
451
+ );
452
+ const draft = drafts[queue.id] ?? { customerName: '', customerPhone: '', customerEmail: '', priority: 0 };
453
+
454
+ return (
455
+ <article key={queue.id} className="card modules-scheduling__queue-card">
456
+ <div className="modules-scheduling__queue-header">
457
+ <div>
458
+ <h3 className="text-section-title">
459
+ {queue.name} <span className="text-muted">({queue.code})</span>
460
+ </h3>
461
+ <div className="modules-scheduling__queue-meta">
462
+ <span className={statusTone(queue.status)}>{copy.statuses[queue.status]}</span>
463
+ <span>
464
+ {copy.queueMetricsWaiting}: <strong>{waiting.length}</strong>
465
+ </span>
466
+ <span>
467
+ {copy.queueMetricsServing}: <strong>{active.length}</strong>
468
+ </span>
469
+ <span>
470
+ {copy.queueMetricsDone}: <strong>{finished.length}</strong>
471
+ </span>
472
+ </div>
473
+ </div>
474
+ <div className="modules-scheduling__queue-actions">
475
+ <button
476
+ type="button"
477
+ className="btn-primary btn-sm"
478
+ onClick={() => void handleQueueAction(queue, 'call-next')}
479
+ disabled={queue.status !== 'active' || queueMutation.isPending}
480
+ >
481
+ {copy.callNext}
482
+ </button>
483
+ {queue.status === 'active' ? (
484
+ <button
485
+ type="button"
486
+ className="btn-secondary btn-sm"
487
+ onClick={() => void handleQueueAction(queue, 'pause')}
488
+ disabled={queueMutation.isPending}
489
+ >
490
+ {copy.pauseQueue}
491
+ </button>
492
+ ) : null}
493
+ {queue.status === 'paused' ? (
494
+ <button
495
+ type="button"
496
+ className="btn-secondary btn-sm"
497
+ onClick={() => void handleQueueAction(queue, 'reopen')}
498
+ disabled={queueMutation.isPending}
499
+ >
500
+ {copy.reopenQueue}
501
+ </button>
502
+ ) : null}
503
+ {queue.status !== 'closed' ? (
504
+ <button
505
+ type="button"
506
+ className="btn-danger btn-sm"
507
+ onClick={() => void handleQueueAction(queue, 'close')}
508
+ disabled={queueMutation.isPending}
509
+ >
510
+ {copy.closeQueue}
511
+ </button>
512
+ ) : null}
513
+ </div>
514
+ </div>
515
+
516
+ <div className="modules-scheduling__queue-ticket-form">
517
+ <div className="modules-scheduling__queue-ticket-form-header">
518
+ <strong>{copy.issueTicketTitle}</strong>
519
+ <span>{copy.issueTicketDescription}</span>
520
+ </div>
521
+ <div className="modules-scheduling__queue-ticket-form-grid">
522
+ <div className="form-group grow">
523
+ <label htmlFor={`queue-name-${queue.id}`}>{copy.customerNameLabel}</label>
524
+ <input
525
+ id={`queue-name-${queue.id}`}
526
+ value={draft.customerName}
527
+ onChange={(event) =>
528
+ setDrafts((current) => ({
529
+ ...current,
530
+ [queue.id]: { ...draft, customerName: event.target.value },
531
+ }))
532
+ }
533
+ />
534
+ </div>
535
+ <div className="form-group grow">
536
+ <label htmlFor={`queue-phone-${queue.id}`}>{copy.customerPhoneLabel}</label>
537
+ <input
538
+ id={`queue-phone-${queue.id}`}
539
+ value={draft.customerPhone}
540
+ onChange={(event) =>
541
+ setDrafts((current) => ({
542
+ ...current,
543
+ [queue.id]: { ...draft, customerPhone: event.target.value },
544
+ }))
545
+ }
546
+ />
547
+ </div>
548
+ <div className="form-group grow">
549
+ <label htmlFor={`queue-email-${queue.id}`}>{copy.customerEmailLabel}</label>
550
+ <input
551
+ id={`queue-email-${queue.id}`}
552
+ value={draft.customerEmail}
553
+ onChange={(event) =>
554
+ setDrafts((current) => ({
555
+ ...current,
556
+ [queue.id]: { ...draft, customerEmail: event.target.value },
557
+ }))
558
+ }
559
+ />
560
+ </div>
561
+ <div className="form-group">
562
+ <label htmlFor={`queue-priority-${queue.id}`}>{copy.priorityLabel}</label>
563
+ <input
564
+ id={`queue-priority-${queue.id}`}
565
+ type="number"
566
+ min={0}
567
+ max={9}
568
+ value={draft.priority}
569
+ onChange={(event) =>
570
+ setDrafts((current) => ({
571
+ ...current,
572
+ [queue.id]: { ...draft, priority: Number(event.target.value || 0) },
573
+ }))
574
+ }
575
+ />
576
+ </div>
577
+ </div>
578
+ <button
579
+ type="button"
580
+ className="btn-secondary btn-sm"
581
+ disabled={!draft.customerName.trim() || !draft.customerPhone.trim() || issueMutation.isPending || queue.status === 'closed'}
582
+ onClick={() => void issueMutation.mutateAsync({ queueId: queue.id, draft })}
583
+ >
584
+ {issueMutation.isPending ? copy.issuingTicket : copy.issueTicket}
585
+ </button>
586
+ </div>
587
+
588
+ <div className="modules-scheduling__queue-columns">
589
+ {[
590
+ { title: copy.waitingColumn, items: waiting },
591
+ { title: copy.activeColumn, items: active },
592
+ { title: copy.finishedColumn, items: finished },
593
+ ].map((column) => (
594
+ <section key={column.title} className="modules-scheduling__queue-column">
595
+ <div className="modules-scheduling__queue-column-title">{column.title}</div>
596
+ {column.items.length === 0 ? (
597
+ <div className="modules-scheduling__queue-empty">{copy.noTickets}</div>
598
+ ) : (
599
+ column.items.map((ticket) => (
600
+ <div key={ticket.id} className="modules-scheduling__queue-ticket">
601
+ <div className="modules-scheduling__queue-ticket-main">
602
+ <strong>{ticket.label}</strong>
603
+ <span className={statusTone(ticket.status)}>{copy.statuses[ticket.status]}</span>
604
+ </div>
605
+ <div className="modules-scheduling__queue-ticket-meta">
606
+ <span>
607
+ #{ticket.number ?? '—'} · {copy.statusLabel}: {copy.statuses[ticket.status]}
608
+ </span>
609
+ </div>
610
+ {ticketActions(ticket.status).length > 0 ? (
611
+ <div className="modules-scheduling__queue-ticket-actions">
612
+ {ticketActions(ticket.status).map((action) => (
613
+ <button
614
+ key={action}
615
+ type="button"
616
+ className={
617
+ action === 'cancel' || action === 'no_show'
618
+ ? 'btn-danger btn-sm'
619
+ : 'btn-secondary btn-sm'
620
+ }
621
+ onClick={() => void handleTicketAction(queue.id, ticket.id, action)}
622
+ disabled={ticketMutation.isPending}
623
+ >
624
+ {action === 'serve'
625
+ ? copy.serveTicket
626
+ : action === 'complete'
627
+ ? copy.completeTicket
628
+ : action === 'no_show'
629
+ ? copy.noShowTicket
630
+ : action === 'cancel'
631
+ ? copy.cancelTicket
632
+ : copy.returnToWaiting}
633
+ </button>
634
+ ))}
635
+ </div>
636
+ ) : null}
637
+ </div>
638
+ ))
639
+ )}
640
+ </section>
641
+ ))}
642
+ </div>
643
+ </article>
644
+ );
645
+ })}
646
+ </div>
647
+ )}
648
+ </section>
649
+ );
650
+ }