An interactive demonstration of follow-up customer queue workflows. Explore prioritizations, chronological schedules, and status categorizations.
Follow-Up Queue Laboratory
Track outstanding customer call-backs, calendar schedules, and categorized follow-up cards.
Follow-Up Queue
15 total
Loretta Bassett
2026-03-22 10:00 AM
5d overdue
Last: 2026-03-20 via phone
Customer disputes $340 charge on invoice. Claims technician was late and promised discount. 5 days overdue — escalation risk.
Carolyn Hess
2026-03-24 11:00 AM
3d overdue
Last: 2026-03-22 via email
Estimate for full roof replacement sent 3/21. Customer said she needed to check with husband. 3 days overdue.
Dwight Callaghan
2026-03-26 9:00 AM
1d overdue
Last: 2026-03-24 via email
Survey email sent 3/24, link not clicked. Service was AC installation 3/23.
Sandra Kowalski
Today 9:00 AM
Last: 2026-03-25 via phone
Customer requested AM callback. Confirm HVAC tune-up for Tuesday.
Howard Briggs
Today 9:30 AM
Last: 2026-03-26 via phone
Customer angry about technician no-show on 3/26. Rescheduled but wants manager callback. Urgent — do not delay.
Marcus Delgado
Today 10:30 AM
Last: 2026-03-26 via text
Texted reminder yesterday — no reply. Call to confirm plumbing inspection.
Tiffany Morse
Today 11:00 AM
Last: 2026-03-26 via text
Text reminder sent. Service was drain cleaning 3/25. High-value customer.
Diana Whitmore
Today 11:30 AM
Last: 2026-03-26 via email
Complaint filed 3/26 — AC unit installed 3/24 not cooling properly. Requesting re-service and partial refund discussion.
Jerome Tillman
Today 3:00 PM
Last: 2026-03-25 via phone
Kitchen remodel estimate $18,400. Customer wants to compare 2 other quotes.
Curtis Flanagan
Today 4:00 PM
Last: 2026-03-26 via email
Requesting itemized breakdown of labor charges on invoice INV-9072.
Patricia Nguyen
Tomorrow 8:00 AM
Last: 2026-03-24 via email
Email sent 3/24, no response. Window installation tomorrow AM.
Gwendolyn Park
Tomorrow 9:30 AM
Last: 2026-03-25 via phone
Payment plan request for $2,100 balance. Pre-approved for 3-month split per manager.
Rachel Pomeroy
Tomorrow 10:00 AM
Last: 2026-03-26 via text
Basement waterproofing estimate pending approval. Customer is on vacation, back today.
Arnold Espinoza
Tomorrow 1:00 PM
Last: 2026-03-25 via email
Furnace replacement completed 3/25. Customer seemed satisfied. 5-star potential.
Brian Okafor
Tomorrow 2:00 PM
Last: 2026-03-26 via phone
Left voicemail on 3/26. Electrical panel inspection needs PM slot confirmed.
Variant1_PriorityQueueList.tsx (Widget Implementation)
import { useState, useMemo } from "react";
import {
Paper,
Group,
Stack,
Text,
Badge,
ScrollArea,
Tabs,
ActionIcon,
Tooltip,
Box,
Divider,
} from "@mantine/core";
import {
HiCheckCircle,
HiPhone,
HiEnvelope,
HiChatBubbleLeftRight,
HiExclamationTriangle,
HiClock,
} from "react-icons/hi2";
import type { FollowUpItem, FollowUpType, ContactMethod } from "./types";
import { sampleFollowUps } from "./sampleData";
// ── Constants ────────────────────────────────────────────────────────────────
const TYPE_COLORS: Record<FollowUpType, string> = {
"appointment-confirmation": "#228be6",
"estimate-follow-up": "#fab005",
"post-service-survey": "#40c057",
"billing-inquiry": "#fd7e14",
complaint: "#fa5252",
};
const TYPE_LABELS: Record<FollowUpType, string> = {
"appointment-confirmation": "Confirmation",
"estimate-follow-up": "Estimate",
"post-service-survey": "Survey",
"billing-inquiry": "Billing",
complaint: "Complaint",
};
const CONTACT_ICONS: Record<ContactMethod, React.ReactNode> = {
phone: <HiPhone size={12} />,
email: <HiEnvelope size={12} />,
text: <HiChatBubbleLeftRight size={12} />,
};
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatDueDate(dueDate: string, dueTime: string): string {
const today = "2026-03-27";
const tomorrow = "2026-03-28";
if (dueDate === today) return `Today ${formatTime(dueTime)}`;
if (dueDate === tomorrow) return `Tomorrow ${formatTime(dueTime)}`;
return `${dueDate} ${formatTime(dueTime)}`;
}
function formatTime(time: string): string {
const [h, m] = time.split(":").map(Number);
const period = h >= 12 ? "PM" : "AM";
const hour = h % 12 === 0 ? 12 : h % 12;
return `${hour}:${String(m).padStart(2, "0")} ${period}`;
}
function isToday(dueDate: string): boolean {
return dueDate === "2026-03-27";
}
function isThisWeek(dueDate: string): boolean {
return dueDate >= "2026-03-27" && dueDate <= "2026-04-02";
}
function sortByPriority(items: FollowUpItem[]): FollowUpItem[] {
return [...items].sort((a, b) => {
// Overdue first, sorted by daysOverdue desc
if (a.isOverdue && b.isOverdue) return b.daysOverdue - a.daysOverdue;
if (a.isOverdue) return -1;
if (b.isOverdue) return 1;
// Then by date/time asc
const dtA = `${a.dueDate}T${a.dueTime}`;
const dtB = `${b.dueDate}T${b.dueTime}`;
return dtA < dtB ? -1 : dtA > dtB ? 1 : 0;
});
}
// ── Row Component ─────────────────────────────────────────────────────────────
interface RowProps {
item: FollowUpItem;
onDone: (id: string) => void;
}
function QueueRow({ item, onDone }: RowProps) {
const isComplaint = item.type === "complaint";
const rowBg = item.isOverdue
? "rgba(250, 82, 82, 0.04)"
: isComplaint
? "rgba(250, 82, 82, 0.03)"
: undefined;
return (
<Box
style={{
backgroundColor: rowBg,
borderLeft: item.isOverdue
? "3px solid #fa5252"
: isComplaint
? "3px solid #fa5252"
: "3px solid transparent",
padding: "10px 12px",
borderRadius: 6,
transition: "background 0.15s",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="xs">
{/* Left: name + metadata */}
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center" wrap="nowrap">
{isComplaint && !item.isOverdue && <HiExclamationTriangle size={14} color="#fa5252" />}
<Text fw={600} size="sm" style={{ whiteSpace: "nowrap" }}>
{item.customerName}
</Text>
<Badge
size="xs"
variant="light"
style={{
backgroundColor: `${TYPE_COLORS[item.type]}18`,
color: TYPE_COLORS[item.type],
border: `1px solid ${TYPE_COLORS[item.type]}40`,
flexShrink: 0,
}}
>
{TYPE_LABELS[item.type]}
</Badge>
</Group>
<Group gap="xs" align="center">
<HiClock size={11} color="#868e96" />
<Text size="xs" c={item.isOverdue ? "red" : "dimmed"} fw={item.isOverdue ? 600 : 400}>
{formatDueDate(item.dueDate, item.dueTime)}
</Text>
{item.isOverdue && (
<Text size="xs" c="red" fw={700}>
{item.daysOverdue}d overdue
</Text>
)}
</Group>
<Group gap={4} align="center">
<Box style={{ color: "#868e96" }}>{CONTACT_ICONS[item.lastContactMethod]}</Box>
<Text size="xs" c="dimmed">
Last: {item.lastContactDate} via {item.lastContactMethod}
</Text>
</Group>
{item.notes && (
<Text
size="xs"
c="dimmed"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 340,
}}
>
{item.notes}
</Text>
)}
</Stack>
{/* Right: action */}
<Tooltip label="Mark done" withArrow position="left">
<ActionIcon
variant="subtle"
color="green"
size="md"
onClick={() => onDone(item.id)}
style={{ flexShrink: 0, marginTop: 2 }}
>
<HiCheckCircle size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Box>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export function Variant1_PriorityQueueList() {
const [items, setItems] = useState<FollowUpItem[]>(sampleFollowUps);
const [activeTab, setActiveTab] = useState<string | null>("all");
const handleDone = (id: string) => {
setItems((prev) => prev.filter((i) => i.id !== id));
};
const counts = useMemo(() => {
const overdue = items.filter((i) => i.isOverdue).length;
const today = items.filter((i) => isToday(i.dueDate) && !i.isOverdue).length;
const week = items.filter((i) => isThisWeek(i.dueDate) && !i.isOverdue).length;
return { all: items.length, overdue, today, week };
}, [items]);
const visibleItems = useMemo(() => {
let filtered = items;
if (activeTab === "overdue") filtered = items.filter((i) => i.isOverdue);
else if (activeTab === "today")
filtered = items.filter((i) => isToday(i.dueDate) && !i.isOverdue);
else if (activeTab === "week")
filtered = items.filter((i) => isThisWeek(i.dueDate) && !i.isOverdue);
return sortByPriority(filtered);
}, [items, activeTab]);
return (
<Paper
withBorder
shadow="sm"
radius="md"
p="md"
style={{ display: "flex", flexDirection: "column", height: "100%" }}
>
{/* Header */}
<Group justify="space-between" mb="xs">
<Group gap="sm" align="center">
<Text fw={700} size="md">
Follow-Up Queue
</Text>
{counts.overdue > 0 && (
<Badge color="red" size="sm" variant="filled" circle={false}>
{counts.overdue} overdue
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{counts.all} total
</Text>
</Group>
{/* Filter Tabs */}
<Tabs value={activeTab} onChange={setActiveTab} mb="xs">
<Tabs.List>
<Tabs.Tab value="all">
All{" "}
<Text span size="xs" c="dimmed" ml={4}>
{counts.all}
</Text>
</Tabs.Tab>
<Tabs.Tab value="overdue" color="red">
Overdue{" "}
<Text span size="xs" c="red" ml={4} fw={counts.overdue > 0 ? 700 : 400}>
{counts.overdue}
</Text>
</Tabs.Tab>
<Tabs.Tab value="today">
Due Today{" "}
<Text span size="xs" c="dimmed" ml={4}>
{counts.today}
</Text>
</Tabs.Tab>
<Tabs.Tab value="week">
This Week{" "}
<Text span size="xs" c="dimmed" ml={4}>
{counts.week}
</Text>
</Tabs.Tab>
</Tabs.List>
</Tabs>
<Divider mb="xs" />
{/* List */}
<ScrollArea style={{ flex: 1 }} offsetScrollbars>
{visibleItems.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="xl">
No follow-ups in this category.
</Text>
) : (
<Stack gap="xs">
{visibleItems.map((item) => (
<QueueRow key={item.id} item={item} onDone={handleDone} />
))}
</Stack>
)}
</ScrollArea>
</Paper>
);
}