feat: add lab management layout, barcode generation, and dental education page

- Redesign lab management tab with patient search table and action buttons
- Add react-barcode integration: generates CODE128 barcode from patient name with print and download support
- Add Dental Education page and sidebar entry between Cloud Storage and AI Input Agent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 13:03:58 -04:00
parent 998ef0ac95
commit ac4d8c4c74
7 changed files with 241 additions and 414 deletions

View File

@@ -68,6 +68,7 @@
"pdfjs-dist": "^3.11.174",
"postcss": "^8.4.47",
"react": "^19.1.0",
"react-barcode": "^1.6.1",
"react-contexify": "^6.0.0",
"react-day-picker": "9.7.0",
"react-dnd": "^16.0.1",

View File

@@ -29,6 +29,7 @@ const ReportsPage = lazy(() => import("./pages/reports-page"));
const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page"));
const JobMonitorPage = lazy(() => import("./pages/job-monitor-page"));
const ChartPage = lazy(() => import("./pages/chart-page"));
const DentalEducationPage = lazy(() => import("./pages/dental-education-page"));
const AiInputAgentPage = lazy(() => import("./pages/ai-input-agent-page"));
const DentalShoppingSearchTagPage = lazy(() => import("./pages/dental-shopping-search-tag-page"));
const DentalShoppingLoginInfoPage = lazy(() => import("./pages/dental-shopping-login-info-page"));
@@ -65,6 +66,7 @@ function Router() {
/>
<ProtectedRoute path="/reports" component={() => <ReportsPage />} />
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />} />
<ProtectedRoute path="/dental-education" component={() => <DentalEducationPage />} />
<ProtectedRoute path="/ai-input-agent" component={() => <AiInputAgentPage />} />
<ProtectedRoute path="/dental-shopping/search-tag" component={() => <DentalShoppingSearchTagPage />} />
<ProtectedRoute path="/dental-shopping/login-info" component={() => <DentalShoppingLoginInfoPage />} />

View File

@@ -0,0 +1,116 @@
import { useRef } from "react";
import Barcode from "react-barcode";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Download, Printer } from "lucide-react";
import { Patient } from "@repo/db/types";
interface BarcodeModalProps {
open: boolean;
onClose: () => void;
patient: Patient;
}
export function BarcodeModal({ open, onClose, patient }: BarcodeModalProps) {
const barcodeRef = useRef<HTMLDivElement>(null);
const patientLabel = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
const barcodeValue = patientLabel || "PATIENT";
const handlePrint = () => {
const svg = barcodeRef.current?.querySelector("svg");
if (!svg) return;
const svgData = new XMLSerializer().serializeToString(svg);
const printWindow = window.open("", "_blank", "width=400,height=300");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Barcode — ${patientLabel}</title>
<style>
body { margin: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; font-family: sans-serif; }
p { margin: 4px 0 0; font-size: 13px; color: #333; }
</style>
</head>
<body>
${svgData}
<p>${patientLabel}</p>
<script>window.onload = () => { window.print(); window.close(); }<\/script>
</body>
</html>
`);
printWindow.document.close();
};
const handleDownload = () => {
const svg = barcodeRef.current?.querySelector("svg");
if (!svg) return;
const canvas = document.createElement("canvas");
const svgData = new XMLSerializer().serializeToString(svg);
const img = new Image();
const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
const link = document.createElement("a");
link.download = `barcode-${patientLabel.replace(/\s+/g, "-")}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
};
img.src = url;
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Patient Barcode</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-4">
<div ref={barcodeRef}>
<Barcode
value={barcodeValue}
format="CODE128"
width={2}
height={80}
displayValue={true}
fontSize={13}
margin={10}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handlePrint} className="gap-2">
<Printer className="h-4 w-4" />
Print
</Button>
<Button onClick={handleDownload} className="gap-2">
<Download className="h-4 w-4" />
Download PNG
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,364 +1,70 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Plus, Pencil, Trash2, Package } from "lucide-react";
type LabStatus = "pending" | "in-lab" | "received" | "delivered" | "cancelled";
interface LabOrder {
id: number;
orderDate: string;
dueDate: string;
tooth: string;
caseType: string;
lab: string;
shade: string;
status: LabStatus;
rush: boolean;
notes: string;
trackingNumber: string;
}
const STATUS_COLORS: Record<LabStatus, string> = {
pending: "bg-yellow-100 text-yellow-700 border-yellow-200",
"in-lab": "bg-blue-100 text-blue-700 border-blue-200",
received: "bg-green-100 text-green-700 border-green-200",
delivered: "bg-gray-100 text-gray-600 border-gray-200",
cancelled: "bg-red-100 text-red-600 border-red-200",
};
const STATUS_LABELS: Record<LabStatus, string> = {
pending: "Pending",
"in-lab": "In Lab",
received: "Received",
delivered: "Delivered",
cancelled: "Cancelled",
};
const CASE_TYPES = [
"Crown PFM",
"Crown All Ceramic",
"Crown Zirconia",
"Crown Gold",
"Bridge PFM",
"Bridge Zirconia",
"Implant Crown",
"Implant Abutment",
"Veneer",
"Inlay / Onlay",
"Full Denture (Upper)",
"Full Denture (Lower)",
"Partial Denture",
"Night Guard",
"Bleaching Tray",
"Retainer",
"Diagnostic Model",
"Other",
];
const COMMON_LABS = [
"Dental Arts Lab",
"National Dentex",
"Glidewell Dental",
"Henry Schein Lab",
"Affordable Dentures Lab",
"Local Lab",
];
const SHADES = [
"A1", "A2", "A3", "A3.5", "A4",
"B1", "B2", "B3", "B4",
"C1", "C2", "C3", "C4",
"D2", "D3", "D4",
"BL1", "BL2", "BL3", "BL4",
"Custom",
];
let nextId = 1;
const newOrder = (): LabOrder => ({
id: nextId++,
orderDate: new Date().toISOString().substring(0, 10),
dueDate: "",
tooth: "",
caseType: "",
lab: "",
shade: "",
status: "pending",
rush: false,
notes: "",
trackingNumber: "",
});
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PatientTable } from "@/components/patients/patient-table";
import { BarcodeModal } from "@/components/chart/barcode-modal";
import { Barcode, FileText } from "lucide-react";
import { Patient } from "@repo/db/types";
export function LabManagementTab() {
const [orders, setOrders] = useState<LabOrder[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LabOrder>(newOrder());
const openAdd = () => {
setEditing(newOrder());
setDialogOpen(true);
};
const openEdit = (order: LabOrder) => {
setEditing({ ...order });
setDialogOpen(true);
};
const handleSave = () => {
setOrders((prev) => {
const idx = prev.findIndex((o) => o.id === editing.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = editing;
return next;
}
return [...prev, editing];
});
setDialogOpen(false);
};
const handleDelete = (id: number) => {
setOrders((prev) => prev.filter((o) => o.id !== id));
};
const pending = orders.filter((o) => o.status === "pending" || o.status === "in-lab").length;
const rush = orders.filter((o) => o.rush && o.status !== "delivered" && o.status !== "cancelled").length;
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [barcodeOpen, setBarcodeOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex gap-4 text-sm text-gray-600">
{pending > 0 && <span>Open orders: <strong>{pending}</strong></span>}
{rush > 0 && <span className="text-red-600">Rush: <strong>{rush}</strong></span>}
{orders.length === 0 && <span>No lab orders yet</span>}
</div>
<Button size="sm" onClick={openAdd} className="gap-1.5">
<Plus className="h-4 w-4" />
New Lab Order
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-24">Order Date</TableHead>
<TableHead className="w-16">Tooth</TableHead>
<TableHead>Case Type</TableHead>
<TableHead>Lab</TableHead>
<TableHead className="w-16 text-center">Shade</TableHead>
<TableHead className="w-24">Due</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-20 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-gray-400 py-10">
<div className="flex flex-col items-center gap-2">
<Package className="h-8 w-8 text-gray-300" />
<span>No lab orders yet. Click "New Lab Order" to add one.</span>
</div>
</TableCell>
</TableRow>
) : (
orders.map((order) => (
<TableRow key={order.id} className={order.rush ? "bg-red-50/40" : ""}>
<TableCell className="text-sm">{order.orderDate}</TableCell>
<TableCell className="font-mono text-sm">{order.tooth || "—"}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">{order.caseType}</span>
{order.rush && (
<Badge className="text-[10px] bg-red-100 text-red-600 border-red-200 px-1 py-0" variant="outline">
RUSH
</Badge>
)}
</div>
{order.notes && (
<p className="text-xs text-gray-400 truncate max-w-xs mt-0.5">{order.notes}</p>
)}
</TableCell>
<TableCell className="text-sm text-gray-600">{order.lab || "—"}</TableCell>
<TableCell className="text-center text-sm">{order.shade || "—"}</TableCell>
<TableCell className="text-sm text-gray-600">{order.dueDate || "—"}</TableCell>
<TableCell>
<Badge className={`text-xs border ${STATUS_COLORS[order.status]}`} variant="outline">
{STATUS_LABELS[order.status]}
</Badge>
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(order)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(order.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Lab Order</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
<div className="space-y-1">
<Label className="text-xs">Order Date</Label>
<Input
type="date"
value={editing.orderDate}
onChange={(e) => setEditing((d) => ({ ...d, orderDate: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Due Date</Label>
<Input
type="date"
value={editing.dueDate}
onChange={(e) => setEditing((d) => ({ ...d, dueDate: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Tooth #</Label>
<Input
placeholder="e.g. 14"
value={editing.tooth}
onChange={(e) => setEditing((d) => ({ ...d, tooth: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Shade</Label>
<Select
value={editing.shade}
onValueChange={(v) => setEditing((d) => ({ ...d, shade: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{SHADES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Case Type</Label>
<Select
value={editing.caseType}
onValueChange={(v) => setEditing((d) => ({ ...d, caseType: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select case type..." />
</SelectTrigger>
<SelectContent>
{CASE_TYPES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Lab</Label>
<Select
value={editing.lab}
onValueChange={(v) => setEditing((d) => ({ ...d, lab: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select lab..." />
</SelectTrigger>
<SelectContent>
{COMMON_LABS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Status</Label>
<Select
value={editing.status}
onValueChange={(v) => setEditing((d) => ({ ...d, status: v as LabStatus }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(STATUS_LABELS) as LabStatus[]).map((s) => (
<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Tracking #</Label>
<Input
placeholder="Optional"
value={editing.trackingNumber}
onChange={(e) => setEditing((d) => ({ ...d, trackingNumber: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="col-span-2 flex items-center gap-2">
<input
type="checkbox"
id="rush"
checked={editing.rush}
onChange={(e) => setEditing((d) => ({ ...d, rush: e.target.checked }))}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="rush" className="text-sm cursor-pointer text-red-600 font-medium">
Rush Order
</Label>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Notes</Label>
<Textarea
placeholder="Special instructions, shade details..."
value={editing.notes}
onChange={(e) => setEditing((d) => ({ ...d, notes: e.target.value }))}
className="text-sm resize-none"
rows={2}
/>
</div>
<div className="space-y-6">
{/* Action buttons */}
<Card>
<CardHeader>
<CardTitle>Lab Actions</CardTitle>
<CardDescription>
{selectedPatient
? `Selected patient: ${selectedPatient.firstName} ${selectedPatient.lastName}`
: "Select a patient below to get started."}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-4">
<Button
variant="outline"
className="gap-2"
disabled={!selectedPatient}
onClick={() => setBarcodeOpen(true)}
>
<Barcode className="h-4 w-4" />
Create a Barcode
</Button>
<Button variant="outline" className="gap-2" disabled>
<FileText className="h-4 w-4" />
Lab RX
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!editing.caseType}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
{/* Patient search / selection */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>Select a patient by clicking the checkbox.</CardDescription>
</CardHeader>
<CardContent>
<PatientTable
allowView={true}
allowDelete={false}
allowCheckbox={true}
allowEdit={false}
onSelectPatient={setSelectedPatient}
/>
</CardContent>
</Card>
{selectedPatient && (
<BarcodeModal
open={barcodeOpen}
onClose={() => setBarcodeOpen(false)}
patient={selectedPatient}
/>
)}
</div>
);
}

View File

@@ -30,6 +30,7 @@ import {
Building2,
Timer,
BookOpen,
GraduationCap,
ShoppingCart,
Search,
KeyRound,
@@ -169,6 +170,11 @@ export function Sidebar() {
path: "/cloud-storage",
icon: <Cloud className="h-5 w-5 text-sky-500" />,
},
{
name: "Dental Education",
path: "/dental-education",
icon: <GraduationCap className="h-5 w-5 text-lime-600" />,
},
{
name: "AI Input Agent",
path: "/ai-input-agent",

View File

@@ -0,0 +1,27 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { GraduationCap } from "lucide-react";
export default function DentalEducationPage() {
return (
<div className="container mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dental Education</h1>
<p className="text-muted-foreground">Educational resources for dental staff and patients.</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GraduationCap className="h-5 w-5 text-lime-600" />
Coming Soon
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Dental education content will be available here.
</p>
</CardContent>
</Card>
</div>
);
}