An interactive demonstration of field service scheduling and resource allocation. Explore swimlane timelines, status boards, and action-oriented item lists.
Dispatch Board Laboratory
Manage job scheduling, service technician assignments, and status swimlanes interactively.
Fri, Mar 27
6a
7a
8a
9a
10a
11a
12
1p
2p
3p
4p
5p
6p
Unassigned
Meridian Storage Facility
Security Camera System Repair
Calloway Properties LLC
Emergency Boiler Repair
Eastland Dental Group
Medical Air Compressor Service
Marcus
Hendricks & Associates
HVAC Preventive Maintenance
Pinnacle Office Park
Commercial Refrigeration Repair
Chen Family Residence
HVAC System Replacement
Sunrise Bakery
Exhaust Hood Inspection
Lena
Greenfield Apartments
Plumbing Leak Repair
Dr. Patel Medical Office
Backflow Preventer Test
Whitmore Brewing Co.
Gas Line Install
Patterson Residence
Water Heater Replacement
Derek
Fulton Industrial Park
Electrical Panel Upgrade
Northgate Medical Center
Generator Transfer Switch Install
Lakeside Learning Center
Electrical Inspection
Metro Fitness Club
Lighting System Retrofit
Amber
Ridgeline Condominiums
AC Unit Tune-Up
Sorrento Restaurant Group
Walk-In Cooler Repair
Holloway Residence
Furnace Replacement
James
Cardinal Health Campus
Fire Suppression Inspection
Treetops Assisted Living
Sprinkler System Test
Valley View Auto Group
Compressed Air System Service
Harmon Family Residence
Water Softener Install
Priya
Ohio State Satellite Office
BMS Controls Calibration
Cobblestone Commons HOA
Irrigation System Startup
Wellington Towers
Elevator Machine Room HVAC
Scheduled
En Route
In Progress
Completed
Canceled
Variant1_TimelineSwimlane.tsx (Widget Implementation)
import {
Paper,
Group,
Stack,
Text,
Badge,
Box,
Title,
Tooltip,
ScrollArea,
Avatar,
} from "@mantine/core";
import { HiCalendarDays, HiExclamationTriangle } from "react-icons/hi2";
import type { DispatchBoardData, DispatchJob, JobStatus } from "./types";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TIMELINE_START_HOUR = 6; // 6 AM
const TIMELINE_END_HOUR = 18; // 6 PM
const TOTAL_HOURS = TIMELINE_END_HOUR - TIMELINE_START_HOUR;
const HOUR_WIDTH_PX = 72; // px per hour
const SWIMLANE_LABEL_WIDTH = 140; // px for tech label column
const TIMELINE_TOTAL_WIDTH = TOTAL_HOURS * HOUR_WIDTH_PX;
const STATUS_COLORS: Record<JobStatus, string> = {
scheduled: "#228be6",
"en-route": "#fab005",
"in-progress": "#40c057",
completed: "#868e96",
canceled: "#fa5252",
};
const STATUS_LABELS: Record<JobStatus, string> = {
scheduled: "Scheduled",
"en-route": "En Route",
"in-progress": "In Progress",
completed: "Completed",
canceled: "Canceled",
};
const PRIORITY_COLORS: Record<string, string> = {
emergency: "#fa5252",
high: "#fd7e14",
normal: "#228be6",
low: "#868e96",
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Convert "HH:mm" to fractional hours from TIMELINE_START_HOUR */
function timeToOffset(time: string): number {
const [h, m] = time.split(":").map(Number);
return h + m / 60 - TIMELINE_START_HOUR;
}
function offsetToPx(offset: number): number {
return offset * HOUR_WIDTH_PX;
}
function hourLabel(hour: number): string {
if (hour === 0 || hour === 12) return `${hour === 0 ? 12 : hour}`;
if (hour < 12) return `${hour}a`;
return `${hour - 12}p`;
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function JobBlock({ job }: { job: DispatchJob }) {
const startOffset = Math.max(0, timeToOffset(job.startTime));
const endOffset = Math.min(TOTAL_HOURS, timeToOffset(job.endTime));
const widthPx = Math.max(4, offsetToPx(endOffset - startOffset));
const leftPx = offsetToPx(startOffset);
const color = STATUS_COLORS[job.status];
const tooltipLabel = (
<Stack gap={2}>
<Text size="xs" fw={700}>
{job.customerName}
</Text>
<Text size="xs">{job.serviceType}</Text>
<Text size="xs">{job.address}</Text>
<Text size="xs">
{job.startTime} – {job.endTime}
</Text>
<Group gap={4}>
<Text size="xs">{STATUS_LABELS[job.status]}</Text>
<Text size="xs" c={PRIORITY_COLORS[job.priority]}>
• {job.priority}
</Text>
</Group>
</Stack>
);
return (
<Tooltip label={tooltipLabel} position="top" withArrow multiline>
<Box
style={{
position: "absolute",
left: leftPx,
width: widthPx,
top: 6,
bottom: 6,
backgroundColor: color,
borderRadius: 4,
overflow: "hidden",
cursor: "default",
opacity: job.status === "canceled" ? 0.55 : 1,
border: job.priority === "emergency" ? "2px solid #fa5252" : "none",
boxSizing: "border-box",
}}
>
{widthPx > 50 && (
<Text
size="10px"
fw={600}
style={{
color: "#fff",
padding: "2px 5px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
lineHeight: 1.3,
}}
>
{job.customerName}
</Text>
)}
{widthPx > 80 && (
<Text
size="10px"
style={{
color: "rgba(255,255,255,0.85)",
padding: "0 5px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
lineHeight: 1.3,
}}
>
{job.serviceType}
</Text>
)}
</Box>
</Tooltip>
);
}
function TimelineHeader() {
const hours = Array.from({ length: TOTAL_HOURS + 1 }, (_, i) => i + TIMELINE_START_HOUR);
return (
<Box
style={{
position: "relative",
height: 28,
minWidth: TIMELINE_TOTAL_WIDTH,
borderBottom: "1px solid #e9ecef",
flexShrink: 0,
}}
>
{hours.map((hour) => {
const leftPx = offsetToPx(hour - TIMELINE_START_HOUR);
return (
<Box
key={hour}
style={{
position: "absolute",
left: leftPx,
top: 0,
bottom: 0,
display: "flex",
alignItems: "center",
}}
>
<Text
size="10px"
c="dimmed"
fw={600}
style={{ transform: "translateX(-50%)", userSelect: "none" }}
>
{hourLabel(hour)}
</Text>
</Box>
);
})}
</Box>
);
}
function GridLines() {
const hours = Array.from({ length: TOTAL_HOURS + 1 }, (_, i) => i);
return (
<>
{hours.map((i) => (
<Box
key={i}
style={{
position: "absolute",
left: offsetToPx(i),
top: 0,
bottom: 0,
width: 1,
backgroundColor: i === 0 ? "transparent" : "#f1f3f5",
}}
/>
))}
</>
);
}
function SwimlaneRow({
name,
avatar,
jobs,
isLast,
}: {
name: string;
avatar?: string;
jobs: DispatchJob[];
isLast: boolean;
}) {
return (
<Box
style={{
display: "flex",
borderBottom: isLast ? "none" : "1px solid #f1f3f5",
height: 52,
}}
>
{/* Label */}
<Box
style={{
width: SWIMLANE_LABEL_WIDTH,
minWidth: SWIMLANE_LABEL_WIDTH,
display: "flex",
alignItems: "center",
gap: 8,
paddingRight: 8,
borderRight: "1px solid #e9ecef",
flexShrink: 0,
}}
>
<Avatar size={28} radius="xl" color="blue" variant="light">
{avatar ?? name.slice(0, 2).toUpperCase()}
</Avatar>
<Text
size="xs"
fw={600}
style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
>
{name.split(" ")[0]}
</Text>
</Box>
{/* Job blocks */}
<Box
style={{
position: "relative",
flex: 1,
minWidth: TIMELINE_TOTAL_WIDTH,
}}
>
<GridLines />
{jobs.map((job) => (
<JobBlock key={job.id} job={job} />
))}
</Box>
</Box>
);
}
// ---------------------------------------------------------------------------
// Unassigned Jobs strip
// ---------------------------------------------------------------------------
function UnassignedStrip({ jobs }: { jobs: DispatchJob[] }) {
if (jobs.length === 0) return null;
return (
<Box
style={{
display: "flex",
borderBottom: "1px solid #e9ecef",
height: 44,
backgroundColor: "#fff9db",
}}
>
{/* Label */}
<Box
style={{
width: SWIMLANE_LABEL_WIDTH,
minWidth: SWIMLANE_LABEL_WIDTH,
display: "flex",
alignItems: "center",
gap: 6,
paddingRight: 8,
borderRight: "1px solid #e9ecef",
flexShrink: 0,
}}
>
<HiExclamationTriangle size={14} color="#fab005" />
<Text size="xs" fw={700} c="yellow.7" style={{ whiteSpace: "nowrap" }}>
Unassigned
</Text>
</Box>
{/* Blocks */}
<Box style={{ position: "relative", flex: 1, minWidth: TIMELINE_TOTAL_WIDTH }}>
<GridLines />
{jobs.map((job) => (
<JobBlock key={job.id} job={job} />
))}
</Box>
</Box>
);
}
// ---------------------------------------------------------------------------
// Status Legend
// ---------------------------------------------------------------------------
function StatusLegend() {
return (
<Group gap="sm" wrap="wrap">
{(Object.entries(STATUS_COLORS) as [JobStatus, string][]).map(([status, color]) => (
<Group key={status} gap={4} align="center">
<Box
style={{
width: 10,
height: 10,
borderRadius: 2,
backgroundColor: color,
flexShrink: 0,
}}
/>
<Text size="10px" c="dimmed">
{STATUS_LABELS[status]}
</Text>
</Group>
))}
</Group>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
interface Props {
data: DispatchBoardData;
}
export function Variant1_TimelineSwimlane({ data }: Props) {
const totalJobs =
data.technicians.reduce((sum, t) => sum + t.jobs.length, 0) + data.unassignedJobs.length;
return (
<Paper withBorder shadow="sm" radius="md" p="md" style={{ minWidth: 560 }}>
{/* Header */}
<Group justify="space-between" mb="sm">
<Group gap="xs">
<HiCalendarDays size={18} color="#228be6" />
<Title order={4}>Today's Dispatch</Title>
<Text size="xs" c="dimmed">
{new Date(data.date + "T12:00:00").toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})}
</Text>
</Group>
<Group gap="xs">
<Badge variant="light" color="blue" size="sm">
{totalJobs} jobs
</Badge>
{data.unassignedJobs.length > 0 && (
<Badge variant="light" color="yellow" size="sm">
{data.unassignedJobs.length} unassigned
</Badge>
)}
</Group>
</Group>
{/* Timeline body */}
<ScrollArea type="auto" style={{ width: "100%" }}>
<Box style={{ display: "flex", flexDirection: "column" }}>
{/* Fixed header row */}
<Box style={{ display: "flex", flexShrink: 0 }}>
{/* Corner */}
<Box
style={{
width: SWIMLANE_LABEL_WIDTH,
minWidth: SWIMLANE_LABEL_WIDTH,
height: 28,
borderRight: "1px solid #e9ecef",
flexShrink: 0,
}}
/>
{/* Hour labels */}
<TimelineHeader />
</Box>
{/* Unassigned strip */}
<UnassignedStrip jobs={data.unassignedJobs} />
{/* Technician swimlanes */}
{data.technicians.map((tech, idx) => (
<SwimlaneRow
key={tech.id}
name={tech.name}
avatar={tech.avatar}
jobs={tech.jobs}
isLast={idx === data.technicians.length - 1}
/>
))}
</Box>
</ScrollArea>
{/* Legend */}
<Box mt="sm" pt="xs" style={{ borderTop: "1px solid #f1f3f5" }}>
<StatusLegend />
</Box>
</Paper>
);
}