QueueManager
A comprehensive queue management interface for providers to control the walk-in service flow. Shows the customer currently being served, the full queued list with contact details, and action buttons for starting service, completing, marking no-shows, and removing entries. Supports drag-to-reorder for priority changes.
Install
Section titled “Install”npx thebookingkit add queue-managerimport { QueueManager } from "@/components/queue-manager";import { useState } from "react";
export function ProviderQueueManagement() { const [entries, setEntries] = useState([ { id: "entry-1", position: 1, customerName: "John Smith", customerPhone: "+1 555 123 4567", serviceName: "Haircut", durationMinutes: 30, estimatedWaitMinutes: 15, checkedInAt: new Date(), status: "queued", notes: "First time customer", }, ]);
const handleStartService = async (entryId: string) => { await fetch(`/api/queue/${entryId}/start`, { method: "PATCH" }); // Refresh queue... };
const handleCompleteService = async (entryId: string) => { await fetch(`/api/queue/${entryId}/complete`, { method: "PATCH" }); // Refresh queue... };
const handleMarkNoShow = async (entryId: string) => { await fetch(`/api/queue/${entryId}/no-show`, { method: "PATCH" }); // Refresh queue... };
const handleRemove = async (entryId: string) => { await fetch(`/api/queue/${entryId}`, { method: "DELETE" }); // Refresh queue... };
const handleReorder = async (orderedIds: string[]) => { await fetch("/api/queue/reorder", { method: "PATCH", body: JSON.stringify({ orderedIds }), }); // Refresh queue... };
return ( <QueueManager entries={entries} onStartService={handleStartService} onCompleteService={handleCompleteService} onMarkNoShow={handleMarkNoShow} onRemove={handleRemove} onReorder={handleReorder} /> );}export interface QueueManagerProps { /** Current queue entries ordered by position */ entries: QueueManagerEntry[]; /** Called when provider starts service for an entry */ onStartService: (entryId: string) => Promise<void>; /** Called when provider completes service */ onCompleteService: (entryId: string) => Promise<void>; /** Called when a customer is marked as no-show */ onMarkNoShow: (entryId: string) => Promise<void>; /** Called when an entry is removed from queue */ onRemove: (entryId: string) => Promise<void>; /** Called when entries are reordered (receives ordered entry IDs) */ onReorder?: (orderedIds: string[]) => Promise<void>; /** Additional CSS class name */ className?: string; /** Inline styles */ style?: React.CSSProperties;}
export interface QueueManagerEntry { id: string; position: number; customerName: string; customerEmail?: string; customerPhone?: string; serviceName: string; durationMinutes: number; estimatedWaitMinutes: number; checkedInAt: Date; status: "queued" | "in_service"; notes?: string;}Source
Section titled “Source”import React, { useCallback, useState } from "react";import { cn } from "../utils/cn.js";
/** Walk-in queue entry for provider management */export interface QueueManagerEntry { id: string; position: number; customerName: string; customerEmail?: string; customerPhone?: string; serviceName: string; durationMinutes: number; estimatedWaitMinutes: number; checkedInAt: Date; status: "queued" | "in_service"; notes?: string;}
/** Props for the QueueManager component */export interface QueueManagerProps { /** Current queue entries ordered by position */ entries: QueueManagerEntry[]; /** Called when provider starts service for an entry */ onStartService: (entryId: string) => Promise<void>; /** Called when provider completes service */ onCompleteService: (entryId: string) => Promise<void>; /** Called when a customer is marked as no-show */ onMarkNoShow: (entryId: string) => Promise<void>; /** Called when an entry is removed from queue */ onRemove: (entryId: string) => Promise<void>; /** Called when entries are reordered (receives ordered entry IDs) */ onReorder?: (orderedIds: string[]) => Promise<void>; /** Additional CSS class name */ className?: string; /** Inline styles */ style?: React.CSSProperties;}
/** * Provider-facing queue management component. * * Shows the full walk-in queue with customer details, wait times, * and action buttons for managing the service lifecycle. Supports * drag-to-reorder for priority changes. * * @example * ```tsx * <QueueManager * entries={queueEntries} * onStartService={(id) => api.startService(id)} * onCompleteService={(id) => api.completeService(id)} * onMarkNoShow={(id) => api.markNoShow(id)} * onRemove={(id) => api.removeFromQueue(id)} * onReorder={(ids) => api.reorderQueue(providerId, ids)} * /> * ``` */export function QueueManager({ entries, onStartService, onCompleteService, onMarkNoShow, onRemove, onReorder, className, style,}: QueueManagerProps) { const [loadingId, setLoadingId] = useState<string | null>(null); const [dragOverId, setDragOverId] = useState<string | null>(null);
const withLoading = useCallback( (id: string, action: (id: string) => Promise<void>) => async () => { setLoadingId(id); try { await action(id); } finally { setLoadingId(null); } }, [], );
const handleDragStart = (e: React.DragEvent, entryId: string) => { e.dataTransfer.setData("text/plain", entryId); e.dataTransfer.effectAllowed = "move"; };
const handleDragOver = (e: React.DragEvent, entryId: string) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOverId(entryId); };
const handleDragLeave = () => { setDragOverId(null); };
const handleDrop = async (e: React.DragEvent, targetId: string) => { e.preventDefault(); setDragOverId(null);
if (!onReorder) return;
const sourceId = e.dataTransfer.getData("text/plain"); if (sourceId === targetId) return;
const queuedEntries = entries.filter((en) => en.status === "queued"); const ids = queuedEntries.map((en) => en.id); const sourceIdx = ids.indexOf(sourceId); const targetIdx = ids.indexOf(targetId);
if (sourceIdx === -1 || targetIdx === -1) return;
ids.splice(sourceIdx, 1); ids.splice(targetIdx, 0, sourceId);
// Include in-service entries const inServiceIds = entries .filter((en) => en.status === "in_service") .map((en) => en.id);
await onReorder([...inServiceIds, ...ids]); };
const inService = entries.find((e) => e.status === "in_service"); const queued = entries.filter((e) => e.status === "queued");
const isLoading = (id: string) => loadingId === id;
const formatTime = (date: Date) => date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
const getWaitClass = (minutes: number) => { if (minutes > 45) return "tbk-queue-wait-high"; if (minutes > 20) return "tbk-queue-wait-medium"; return "tbk-queue-wait-low"; };
return ( <div className={cn("tbk-queue-manager", className)} style={style}> <div className="tbk-queue-manager-header"> <h2 className="tbk-form-title">Walk-In Queue</h2> <span className="tbk-badge">{entries.length} total</span> </div>
{/* In-Service Entry */} {inService && ( <div className="tbk-queue-in-service"> <div className="tbk-queue-in-service-label">Now Serving</div> <div className="tbk-queue-card tbk-queue-card-active"> <div className="tbk-queue-card-header"> <span className="tbk-queue-card-name"> {inService.customerName} </span> <span className="tbk-queue-card-service"> {inService.serviceName} ({inService.durationMinutes} min) </span> </div> {(inService.customerPhone || inService.customerEmail) && ( <div className="tbk-queue-card-contact"> {inService.customerPhone && ( <span>{inService.customerPhone}</span> )} {inService.customerEmail && ( <span>{inService.customerEmail}</span> )} </div> )} {inService.notes && ( <div className="tbk-queue-card-notes">{inService.notes}</div> )} <div className="tbk-queue-card-actions"> <button type="button" className="tbk-button-primary" onClick={withLoading(inService.id, onCompleteService)} disabled={isLoading(inService.id)} > {isLoading(inService.id) ? "..." : "Complete"} </button> <button type="button" className="tbk-button-danger" onClick={withLoading(inService.id, onMarkNoShow)} disabled={isLoading(inService.id)} > No-Show </button> </div> </div> </div> )}
{/* Queued Entries */} {queued.length > 0 ? ( <div className="tbk-queue-list" role="list"> {queued.map((entry) => ( <div key={entry.id} className={cn( "tbk-queue-card", dragOverId === entry.id && "tbk-queue-card-dragover", )} role="listitem" draggable={!!onReorder} onDragStart={(e) => handleDragStart(e, entry.id)} onDragOver={(e) => handleDragOver(e, entry.id)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, entry.id)} > <div className="tbk-queue-card-header"> <span className="tbk-queue-card-position"> #{entry.position} </span> <span className="tbk-queue-card-name"> {entry.customerName} </span> <span className={cn( "tbk-queue-card-wait", getWaitClass(entry.estimatedWaitMinutes), )} > ~{entry.estimatedWaitMinutes} min </span> </div> <div className="tbk-queue-card-meta"> <span>{entry.serviceName} ({entry.durationMinutes} min)</span> <span>Checked in {formatTime(entry.checkedInAt)}</span> </div> {(entry.customerPhone || entry.customerEmail) && ( <div className="tbk-queue-card-contact"> {entry.customerPhone && <span>{entry.customerPhone}</span>} {entry.customerEmail && <span>{entry.customerEmail}</span>} </div> )} {entry.notes && ( <div className="tbk-queue-card-notes">{entry.notes}</div> )} <div className="tbk-queue-card-actions"> <button type="button" className="tbk-button-primary" onClick={withLoading(entry.id, onStartService)} disabled={isLoading(entry.id) || !!inService} title={ inService ? "Complete the current service first" : "Start service" } > {isLoading(entry.id) ? "..." : "Start Service"} </button> <button type="button" className="tbk-button-secondary" onClick={withLoading(entry.id, onMarkNoShow)} disabled={isLoading(entry.id)} > No-Show </button> <button type="button" className="tbk-button-danger-outline" onClick={withLoading(entry.id, onRemove)} disabled={isLoading(entry.id)} > Remove </button> </div> </div> ))} </div> ) : ( !inService && ( <div className="tbk-queue-empty"> No walk-ins in queue. </div> ) )} </div> );}