feat: add Chart section, colorize sidebar icons, rename nav items, patient action buttons, program bridge

This commit is contained in:
Gitead
2026-04-28 21:38:35 -04:00
parent 5d0d92d524
commit 371dea54f7
10 changed files with 1913 additions and 45 deletions

View File

@@ -28,6 +28,7 @@ const DatabaseManagementPage = lazy(
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 NotFound = lazy(() => import("./pages/not-found"));
function Router() {
@@ -42,6 +43,8 @@ function Router() {
component={() => <AppointmentsPage />}
/>
<ProtectedRoute path="/patients" component={() => <PatientsPage />} />
<ProtectedRoute path="/chart/:section" component={() => <ChartPage />} />
<ProtectedRoute path="/chart" component={() => <ChartPage />} />
<ProtectedRoute path="/settings" component={() => <SettingsPage />} adminOnly />
<ProtectedRoute path="/claims" component={() => <ClaimsPage />} />
<ProtectedRoute

View File

@@ -0,0 +1,364 @@
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: "",
});
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;
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>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!editing.caseType}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,324 @@
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, Printer } from "lucide-react";
interface Prescription {
id: number;
date: string;
medication: string;
dosage: string;
frequency: string;
duration: string;
refills: number;
route: string;
indication: string;
instructions: string;
prescriber: string;
}
const COMMON_MEDICATIONS = [
"Amoxicillin 500mg",
"Amoxicillin 875mg",
"Clindamycin 300mg",
"Clindamycin 150mg",
"Metronidazole 500mg",
"Azithromycin 250mg",
"Ibuprofen 400mg",
"Ibuprofen 600mg",
"Ibuprofen 800mg",
"Acetaminophen 500mg",
"Naproxen 500mg",
"Hydrocodone/APAP 5-325mg",
"Oxycodone 5mg",
"Tramadol 50mg",
"Dexamethasone 4mg",
"Prednisone 20mg",
"Chlorhexidine 0.12% Rinse",
"Nystatin Oral Suspension",
"Fluconazole 150mg",
"Benzocaine Topical",
];
const FREQUENCIES = [
"Once daily (QD)",
"Twice daily (BID)",
"Three times daily (TID)",
"Four times daily (QID)",
"Every 4 hours",
"Every 6 hours",
"Every 8 hours",
"Every 12 hours",
"As needed (PRN)",
"With food",
];
const ROUTES = ["Oral", "Topical", "Sublingual", "Rinse and spit"];
let nextId = 1;
const newRx = (): Prescription => ({
id: nextId++,
date: new Date().toISOString().substring(0, 10),
medication: "",
dosage: "",
frequency: "",
duration: "",
refills: 0,
route: "Oral",
indication: "",
instructions: "",
prescriber: "",
});
export function PrescriptionTab() {
const [prescriptions, setPrescriptions] = useState<Prescription[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Prescription>(newRx());
const openAdd = () => {
setEditing(newRx());
setDialogOpen(true);
};
const openEdit = (rx: Prescription) => {
setEditing({ ...rx });
setDialogOpen(true);
};
const handleSave = () => {
setPrescriptions((prev) => {
const idx = prev.findIndex((r) => r.id === editing.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = editing;
return next;
}
return [...prev, editing];
});
setDialogOpen(false);
};
const handleDelete = (id: number) => {
setPrescriptions((prev) => prev.filter((r) => r.id !== id));
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{prescriptions.length} prescription{prescriptions.length !== 1 ? "s" : ""}
</p>
<Button size="sm" onClick={openAdd} className="gap-1.5">
<Plus className="h-4 w-4" />
New Prescription
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-24">Date</TableHead>
<TableHead>Medication</TableHead>
<TableHead className="w-32">Frequency</TableHead>
<TableHead className="w-24">Duration</TableHead>
<TableHead className="w-16 text-center">Refills</TableHead>
<TableHead>Prescriber</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{prescriptions.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-gray-400 py-10">
No prescriptions yet. Click "New Prescription" to add one.
</TableCell>
</TableRow>
) : (
prescriptions.map((rx) => (
<TableRow key={rx.id}>
<TableCell className="text-sm">{rx.date}</TableCell>
<TableCell>
<div>
<p className="text-sm font-medium">{rx.medication}</p>
<p className="text-xs text-gray-400">{rx.dosage} · {rx.route}</p>
{rx.instructions && (
<p className="text-xs text-gray-400 mt-0.5 truncate max-w-xs">{rx.instructions}</p>
)}
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">{rx.frequency}</TableCell>
<TableCell className="text-sm text-gray-600">{rx.duration}</TableCell>
<TableCell className="text-center">
<Badge variant="secondary" className="text-xs">{rx.refills}</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">{rx.prescriber || "—"}</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" title="Print" onClick={() => window.print()}>
<Printer className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(rx)}>
<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(rx.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>New Prescription</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
<div className="space-y-1">
<Label className="text-xs">Date</Label>
<Input
type="date"
value={editing.date}
onChange={(e) => setEditing((d) => ({ ...d, date: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Route</Label>
<Select
value={editing.route}
onValueChange={(v) => setEditing((d) => ({ ...d, route: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROUTES.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Medication</Label>
<Select
value={editing.medication}
onValueChange={(v) => setEditing((d) => ({ ...d, medication: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select medication..." />
</SelectTrigger>
<SelectContent>
{COMMON_MEDICATIONS.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Dosage</Label>
<Input
placeholder="e.g. 500mg"
value={editing.dosage}
onChange={(e) => setEditing((d) => ({ ...d, dosage: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Frequency</Label>
<Select
value={editing.frequency}
onValueChange={(v) => setEditing((d) => ({ ...d, frequency: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{FREQUENCIES.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Duration</Label>
<Input
placeholder="e.g. 7 days"
value={editing.duration}
onChange={(e) => setEditing((d) => ({ ...d, duration: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Refills</Label>
<Input
type="number"
min="0"
max="12"
value={editing.refills}
onChange={(e) => setEditing((d) => ({ ...d, refills: Number(e.target.value) }))}
className="h-9 text-sm"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Indication</Label>
<Input
placeholder="Reason for prescription"
value={editing.indication}
onChange={(e) => setEditing((d) => ({ ...d, indication: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Patient Instructions</Label>
<Textarea
placeholder="Take with food, avoid alcohol..."
value={editing.instructions}
onChange={(e) => setEditing((d) => ({ ...d, instructions: e.target.value }))}
className="text-sm resize-none"
rows={2}
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Prescriber</Label>
<Input
placeholder="Provider name"
value={editing.prescriber}
onChange={(e) => setEditing((d) => ({ ...d, prescriber: e.target.value }))}
className="h-9 text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!editing.medication}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,253 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Save } from "lucide-react";
type ToothCondition =
| "healthy"
| "cavity"
| "crown"
| "missing"
| "root-canal"
| "bridge"
| "implant"
| "fracture"
| "watch";
interface ToothState {
condition: ToothCondition;
notes: string;
}
const CONDITIONS: { value: ToothCondition; label: string; color: string; bg: string }[] = [
{ value: "healthy", label: "Healthy", color: "border-gray-300", bg: "bg-white" },
{ value: "cavity", label: "Cavity", color: "border-red-500", bg: "bg-red-400" },
{ value: "crown", label: "Crown", color: "border-yellow-500", bg: "bg-yellow-400" },
{ value: "missing", label: "Missing", color: "border-gray-400", bg: "bg-gray-300" },
{ value: "root-canal", label: "Root Canal", color: "border-blue-500", bg: "bg-blue-400" },
{ value: "bridge", label: "Bridge", color: "border-purple-500", bg: "bg-purple-400" },
{ value: "implant", label: "Implant", color: "border-green-500", bg: "bg-green-400" },
{ value: "fracture", label: "Fracture", color: "border-orange-500", bg: "bg-orange-400" },
{ value: "watch", label: "Watch", color: "border-yellow-300", bg: "bg-yellow-100" },
];
const TOOTH_NAMES: Record<number, string> = {
1: "UR 3rd Molar", 2: "UR 2nd Molar", 3: "UR 1st Molar",
4: "UR 2nd Premolar", 5: "UR 1st Premolar", 6: "UR Canine",
7: "UR Lat. Incisor", 8: "UR Cen. Incisor",
9: "UL Cen. Incisor", 10: "UL Lat. Incisor",
11: "UL Canine", 12: "UL 1st Premolar", 13: "UL 2nd Premolar",
14: "UL 1st Molar", 15: "UL 2nd Molar", 16: "UL 3rd Molar",
17: "LL 3rd Molar", 18: "LL 2nd Molar", 19: "LL 1st Molar",
20: "LL 2nd Premolar", 21: "LL 1st Premolar", 22: "LL Canine",
23: "LL Lat. Incisor", 24: "LL Cen. Incisor",
25: "LR Cen. Incisor", 26: "LR Lat. Incisor",
27: "LR Canine", 28: "LR 1st Premolar", 29: "LR 2nd Premolar",
30: "LR 1st Molar", 31: "LR 2nd Molar", 32: "LR 3rd Molar",
};
const initTeeth = (): Record<number, ToothState> => {
const s: Record<number, ToothState> = {};
for (let i = 1; i <= 32; i++) s[i] = { condition: "healthy", notes: "" };
return s;
};
const upperTeeth = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
const lowerTeeth = [32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17];
function getConditionStyle(condition: ToothCondition) {
return CONDITIONS.find((c) => c.value === condition) ?? { value: "healthy" as ToothCondition, label: "Healthy", color: "border-gray-300", bg: "bg-white" };
}
interface ToothBoxProps {
number: number;
state: ToothState;
selected: boolean;
isUpper: boolean;
onClick: () => void;
}
function ToothBox({ number, state, selected, isUpper, onClick }: ToothBoxProps) {
const style = getConditionStyle(state.condition);
const isMissing = state.condition === "missing";
return (
<div className="flex flex-col items-center gap-0.5">
{isUpper && (
<span className="text-[10px] text-gray-500 font-mono leading-none">{number}</span>
)}
<button
onClick={onClick}
title={`#${number} ${TOOTH_NAMES[number]}`}
className={cn(
"w-8 h-10 rounded border-2 transition-all duration-150 flex items-center justify-center relative",
style.bg,
style.color,
selected && "ring-2 ring-offset-1 ring-primary scale-110 z-10",
!selected && "hover:scale-105 hover:z-10",
isMissing && "opacity-50"
)}
>
{isMissing && (
<span className="text-gray-500 text-xs font-bold leading-none select-none"></span>
)}
</button>
{!isUpper && (
<span className="text-[10px] text-gray-500 font-mono leading-none">{number}</span>
)}
</div>
);
}
export function TeethChart() {
const [teeth, setTeeth] = useState(initTeeth);
const [selected, setSelected] = useState<number | null>(null);
const [draft, setDraft] = useState<ToothState>({ condition: "healthy", notes: "" });
const handleSelect = (n: number) => {
setSelected(n);
const t = teeth[n];
if (t) setDraft({ ...t });
};
const handleSave = () => {
if (selected === null) return;
setTeeth((prev) => ({ ...prev, [selected]: { ...draft } }));
};
const conditionCounts = CONDITIONS.slice(1).filter(
(c) => Object.values(teeth).some((t) => t.condition === c.value)
);
return (
<div className="space-y-4">
{/* Tooth chart */}
<div className="bg-gray-50 rounded-lg border p-4 overflow-x-auto">
<div className="flex items-center justify-between mb-3 min-w-max">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Patient's Right (R)</span>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Patient's Left (L)</span>
</div>
{/* Upper arch */}
<div className="mb-1 min-w-max">
<div className="flex gap-1 justify-center pb-1">
{upperTeeth.map((n) => (
<ToothBox
key={n}
number={n}
state={teeth[n]!}
selected={selected === n}
isUpper={true}
onClick={() => handleSelect(n)}
/>
))}
</div>
</div>
{/* Center divider */}
<div className="flex items-center gap-2 my-1 min-w-max">
<div className="flex-1 border-t-2 border-dashed border-gray-300" />
<span className="text-[10px] text-gray-400 font-semibold whitespace-nowrap px-1">OCCLUSAL</span>
<div className="flex-1 border-t-2 border-dashed border-gray-300" />
</div>
{/* Lower arch */}
<div className="mt-1 min-w-max">
<div className="flex gap-1 justify-center pt-1">
{lowerTeeth.map((n) => (
<ToothBox
key={n}
number={n}
state={teeth[n]!}
selected={selected === n}
isUpper={false}
onClick={() => handleSelect(n)}
/>
))}
</div>
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2">
{CONDITIONS.map((c) => (
<div key={c.value} className="flex items-center gap-1.5">
<div className={cn("w-4 h-4 rounded border-2 flex-shrink-0", c.bg, c.color)} />
<span className="text-xs text-gray-600">{c.label}</span>
</div>
))}
</div>
{/* Summary badges */}
{conditionCounts.length > 0 && (
<div className="flex flex-wrap gap-2">
{conditionCounts.map((c) => {
const count = Object.values(teeth).filter((t) => t.condition === c.value).length;
return (
<Badge key={c.value} variant="secondary" className="text-xs">
{c.label}: {count}
</Badge>
);
})}
</div>
)}
{/* Selected tooth detail */}
{selected !== null && (
<div className="border rounded-lg p-4 bg-white space-y-3">
<div>
<p className="font-semibold text-sm text-gray-800">
Tooth #{selected} {TOOTH_NAMES[selected]}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-gray-600">Condition</Label>
<Select
value={draft.condition}
onValueChange={(v) => setDraft((d) => ({ ...d, condition: v as ToothCondition }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITIONS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<div className="flex items-center gap-2">
<div className={cn("w-3 h-3 rounded border flex-shrink-0", c.bg, c.color)} />
{c.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-600">Notes</Label>
<Textarea
className="text-sm resize-none h-9 min-h-0 py-1.5"
placeholder="Clinical notes..."
value={draft.notes}
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
rows={1}
/>
</div>
</div>
<Button size="sm" onClick={handleSave} className="gap-1.5">
<Save className="h-3.5 w-3.5" />
Save
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 } from "lucide-react";
type PlanStatus = "planned" | "in-progress" | "completed" | "cancelled";
interface TreatmentEntry {
id: number;
tooth: string;
procedure: string;
fee: number;
status: PlanStatus;
date: string;
provider: string;
notes: string;
}
const STATUS_COLORS: Record<PlanStatus, string> = {
planned: "bg-blue-100 text-blue-700 border-blue-200",
"in-progress": "bg-yellow-100 text-yellow-700 border-yellow-200",
completed: "bg-green-100 text-green-700 border-green-200",
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
};
const STATUS_LABELS: Record<PlanStatus, string> = {
planned: "Planned",
"in-progress": "In Progress",
completed: "Completed",
cancelled: "Cancelled",
};
const COMMON_PROCEDURES = [
"Exam & X-rays",
"Prophylaxis (Cleaning)",
"Composite Filling",
"Amalgam Filling",
"Crown (PFM)",
"Crown (All-ceramic)",
"Root Canal Treatment",
"Extraction",
"Surgical Extraction",
"Implant Placement",
"Implant Crown",
"Bridge",
"Partial Denture",
"Full Denture",
"Scaling & Root Planing",
"Bleaching",
"Veneer",
];
let nextId = 1;
const newEntry = (): TreatmentEntry => ({
id: nextId++,
tooth: "",
procedure: "",
fee: 0,
status: "planned",
date: new Date().toISOString().substring(0, 10),
provider: "",
notes: "",
});
export function TreatmentPlanTab() {
const [entries, setEntries] = useState<TreatmentEntry[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<TreatmentEntry>(newEntry());
const openAdd = () => {
setEditing(newEntry());
setDialogOpen(true);
};
const openEdit = (entry: TreatmentEntry) => {
setEditing({ ...entry });
setDialogOpen(true);
};
const handleSave = () => {
setEntries((prev) => {
const idx = prev.findIndex((e) => e.id === editing.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = editing;
return next;
}
return [...prev, editing];
});
setDialogOpen(false);
};
const handleDelete = (id: number) => {
setEntries((prev) => prev.filter((e) => e.id !== id));
};
const total = entries.reduce((sum, e) => sum + Number(e.fee), 0);
const completed = entries.filter((e) => e.status === "completed").reduce((sum, e) => sum + Number(e.fee), 0);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex gap-4 text-sm text-gray-600">
<span>Total planned: <strong>${total.toLocaleString()}</strong></span>
<span>Completed: <strong className="text-green-600">${completed.toLocaleString()}</strong></span>
</div>
<Button size="sm" onClick={openAdd} className="gap-1.5">
<Plus className="h-4 w-4" />
Add Treatment
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-20">Tooth</TableHead>
<TableHead>Procedure</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="w-24 text-right">Fee</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-28">Date</TableHead>
<TableHead className="w-20 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-gray-400 py-10">
No treatment entries yet. Click "Add Treatment" to start.
</TableCell>
</TableRow>
) : (
entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="font-mono text-sm">
{entry.tooth || "—"}
</TableCell>
<TableCell>
<div>
<p className="text-sm font-medium">{entry.procedure}</p>
{entry.notes && (
<p className="text-xs text-gray-400 truncate max-w-xs">{entry.notes}</p>
)}
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">{entry.provider || "—"}</TableCell>
<TableCell className="text-right text-sm font-medium">
${Number(entry.fee).toLocaleString()}
</TableCell>
<TableCell>
<Badge className={`text-xs border ${STATUS_COLORS[entry.status]}`} variant="outline">
{STATUS_LABELS[entry.status]}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">{entry.date}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(entry)}>
<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(entry.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>{editing.id in (entries.map(e => e.id)) ? "Edit" : "Add"} Treatment</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
<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">Date</Label>
<Input
type="date"
value={editing.date}
onChange={(e) => setEditing((d) => ({ ...d, date: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Procedure</Label>
<Select
value={editing.procedure}
onValueChange={(v) => setEditing((d) => ({ ...d, procedure: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select procedure..." />
</SelectTrigger>
<SelectContent>
{COMMON_PROCEDURES.map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Fee ($)</Label>
<Input
type="number"
min="0"
value={editing.fee}
onChange={(e) => setEditing((d) => ({ ...d, fee: Number(e.target.value) }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Status</Label>
<Select
value={editing.status}
onValueChange={(v) => setEditing((d) => ({ ...d, status: v as PlanStatus }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(STATUS_LABELS) as PlanStatus[]).map((s) => (
<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Provider</Label>
<Input
placeholder="Provider name"
value={editing.provider}
onChange={(e) => setEditing((d) => ({ ...d, provider: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Notes</Label>
<Input
placeholder="Clinical notes..."
value={editing.notes}
onChange={(e) => setEditing((d) => ({ ...d, notes: e.target.value }))}
className="h-9 text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!editing.procedure}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -13,86 +13,155 @@ import {
Cloud,
Phone,
Activity,
ClipboardList,
LayoutGrid,
ListChecks,
Pill,
Microscope,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { useMemo, useState, useEffect } from "react";
import { useSidebar } from "@/components/ui/sidebar";
import { useAuth } from "@/hooks/use-auth";
type NavChild = {
name: string;
path: string;
icon: React.ReactNode;
};
type NavItem = {
name: string;
path: string;
icon: React.ReactNode;
adminOnly?: boolean;
children?: NavChild[];
};
export function Sidebar() {
const [location] = useLocation();
const { state, openMobile, setOpenMobile } = useSidebar(); // "expanded" | "collapsed"
const { user } = useAuth();
const isAdmin = user?.username === "admin";
const navItems = useMemo(
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
const s = new Set<string>();
if (location.startsWith("/chart")) s.add("/chart");
return s;
});
useEffect(() => {
if (location.startsWith("/chart")) {
setExpandedPaths((prev) => new Set([...prev, "/chart"]));
}
}, [location]);
const togglePath = (path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
};
const navItems: NavItem[] = useMemo(
() => [
{
name: "Dashboard",
path: "/dashboard",
icon: <LayoutDashboard className="h-5 w-5" />,
icon: <LayoutDashboard className="h-5 w-5 text-violet-500" />,
},
{
name: "Patient Connection",
path: "/patient-connection",
icon: <Phone className="h-5 w-5" />,
icon: <Phone className="h-5 w-5 text-green-500" />,
},
{
name: "Appointments",
name: "Schedule",
path: "/appointments",
icon: <Calendar className="h-5 w-5" />,
icon: <Calendar className="h-5 w-5 text-blue-500" />,
},
{
name: "Patients",
name: "Patient Management",
path: "/patients",
icon: <Users className="h-5 w-5" />,
icon: <Users className="h-5 w-5 text-indigo-500" />,
},
{
name: "Eligibility",
name: "Chart",
path: "/chart",
icon: <ClipboardList className="h-5 w-5 text-teal-500" />,
children: [
{
name: "Charting Map",
path: "/chart/charting",
icon: <LayoutGrid className="h-4 w-4 text-teal-400" />,
},
{
name: "Treatment Plan",
path: "/chart/treatment-plan",
icon: <ListChecks className="h-4 w-4 text-orange-400" />,
},
{
name: "Prescription",
path: "/chart/prescription",
icon: <Pill className="h-4 w-4 text-rose-400" />,
},
{
name: "Lab Management",
path: "/chart/lab-management",
icon: <Microscope className="h-4 w-4 text-purple-400" />,
},
],
},
{
name: "Insurance Eligibility",
path: "/insurance-status",
icon: <Shield className="h-5 w-5" />,
icon: <Shield className="h-5 w-5 text-emerald-500" />,
},
{
name: "Claims/PreAuth",
path: "/claims",
icon: <FileCheck className="h-5 w-5" />,
icon: <FileCheck className="h-5 w-5 text-orange-500" />,
},
{
name: "Payments",
name: "Accounts/Payments",
path: "/payments",
icon: <CreditCard className="h-5 w-5" />,
icon: <CreditCard className="h-5 w-5 text-amber-500" />,
},
{
name: "Documents",
path: "/documents",
icon: <FolderOpen className="h-5 w-5" />,
icon: <FolderOpen className="h-5 w-5 text-yellow-500" />,
},
{
name: "Reports",
path: "/reports",
icon: <FileText className="h-5 w-5" />,
icon: <FileText className="h-5 w-5 text-red-400" />,
},
{
name: "Cloud storage",
path: "/cloud-storage",
icon: <Cloud className="h-5 w-5" />,
icon: <Cloud className="h-5 w-5 text-sky-500" />,
},
{
name: "Database Management",
path: "/database-management",
icon: <Database className="h-5 w-5" />,
icon: <Database className="h-5 w-5 text-slate-500" />,
adminOnly: true,
},
{
name: "Job Monitor",
path: "/job-monitor",
icon: <Activity className="h-5 w-5" />,
icon: <Activity className="h-5 w-5 text-rose-500" />,
adminOnly: true,
},
{
name: "Settings",
path: "/settings",
icon: <Settings className="h-5 w-5" />,
icon: <Settings className="h-5 w-5 text-gray-400" />,
adminOnly: true,
},
],
@@ -102,24 +171,80 @@ export function Sidebar() {
return (
<div
className={cn(
// original look
"bg-white border-r border-gray-200 shadow-sm z-20",
// clip during width animation to avoid text peeking
"overflow-hidden will-change-[width]",
// animate width only
"transition-[width] duration-200 ease-in-out",
// MOBILE: overlay below topbar (h = 100vh - 4rem)
openMobile
? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden"
: "hidden md:block",
// DESKTOP: participates in row layout
"md:static md:top-auto md:h-auto md:flex-shrink-0",
state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64"
)}
>
<div className="p-2">
<nav role="navigation" aria-label="Main">
{navItems.filter((item) => !item.adminOnly || isAdmin).map((item) => (
{navItems
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
if (item.children) {
const isParentActive = location.startsWith(item.path);
const isExpanded = expandedPaths.has(item.path);
return (
<div key={item.path}>
<div
className={cn(
"flex items-center justify-between p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer select-none",
isParentActive
? "text-primary font-medium bg-primary/5"
: "text-gray-600 hover:bg-gray-100"
)}
onClick={() => togglePath(item.path)}
>
<div className="flex items-center space-x-3">
{item.icon}
<span className="whitespace-nowrap">{item.name}</span>
</div>
{isExpanded ? (
<ChevronDown className="h-4 w-4 flex-shrink-0 opacity-60" />
) : (
<ChevronRight className="h-4 w-4 flex-shrink-0 opacity-60" />
)}
</div>
{isExpanded && (
<div className="ml-4 border-l border-gray-200 pl-2 mb-1">
{item.children.map((child) => {
const isActive = location === child.path || location.startsWith(child.path + "/");
return (
<Link
to={child.path}
key={child.path}
onClick={() => setOpenMobile(false)}
>
<div
className={cn(
"flex items-center space-x-2 p-2 rounded-md pl-2 mb-0.5 transition-colors cursor-pointer",
isActive
? "text-primary font-medium bg-primary/5 border-l-2 border-primary"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-700"
)}
>
{child.icon}
<span className="whitespace-nowrap select-none text-sm">
{child.name}
</span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
return (
<div key={item.path}>
<Link to={item.path} onClick={() => setOpenMobile(false)}>
<div
@@ -138,7 +263,8 @@ export function Sidebar() {
</div>
</Link>
</div>
))}
);
})}
</nav>
</div>
</div>

View File

@@ -0,0 +1,336 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Switch } from "@/components/ui/switch";
import { Plus, Pencil, Trash2, Link2 } from "lucide-react";
type BridgeCategory =
| "imaging"
| "xray"
| "perio"
| "lab"
| "prescription"
| "billing"
| "other";
interface ProgramBridge {
id: number;
name: string;
category: BridgeCategory;
executablePath: string;
arguments: string;
enabled: boolean;
notes: string;
}
const CATEGORY_LABELS: Record<BridgeCategory, string> = {
imaging: "3D / CBCT Imaging",
xray: "Digital X-Ray",
perio: "Perio Charting",
lab: "Lab Software",
prescription: "e-Prescriptions",
billing: "Billing / Clearinghouse",
other: "Other",
};
const CATEGORY_COLORS: Record<BridgeCategory, string> = {
imaging: "bg-purple-100 text-purple-700 border-purple-200",
xray: "bg-blue-100 text-blue-700 border-blue-200",
perio: "bg-green-100 text-green-700 border-green-200",
lab: "bg-orange-100 text-orange-700 border-orange-200",
prescription: "bg-rose-100 text-rose-700 border-rose-200",
billing: "bg-amber-100 text-amber-700 border-amber-200",
other: "bg-gray-100 text-gray-600 border-gray-200",
};
const COMMON_PROGRAMS = [
"DEXIS",
"Sidexis",
"Schick CDR",
"Apteryx XVWeb",
"Carestream CS Imaging",
"Dolphin Imaging",
"i-CAT Vision",
"Romexis",
"EagleSoft",
"Dentrix",
"Eaglesoft Perio",
"Florida Probe",
"PerioVision",
"DrFirst Rcopia",
"NewCrop eRx",
"DentalConnect",
"Custom",
];
let nextId = 1;
const newBridge = (): ProgramBridge => ({
id: nextId++,
name: "",
category: "other",
executablePath: "",
arguments: "",
enabled: true,
notes: "",
});
export function ProgramBridgeTable() {
const [bridges, setBridges] = useState<ProgramBridge[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<ProgramBridge>(newBridge());
const openAdd = () => {
setEditing(newBridge());
setDialogOpen(true);
};
const openEdit = (bridge: ProgramBridge) => {
setEditing({ ...bridge });
setDialogOpen(true);
};
const handleSave = () => {
setBridges((prev) => {
const idx = prev.findIndex((b) => b.id === editing.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = editing;
return next;
}
return [...prev, editing];
});
setDialogOpen(false);
};
const handleDelete = (id: number) => {
setBridges((prev) => prev.filter((b) => b.id !== id));
};
const toggleEnabled = (id: number) => {
setBridges((prev) =>
prev.map((b) => (b.id === id ? { ...b, enabled: !b.enabled } : b))
);
};
const enabledCount = bridges.filter((b) => b.enabled).length;
return (
<Card>
<CardHeader className="pb-3 pt-4 px-4 flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-indigo-500" />
<CardTitle className="text-base font-semibold">Program Bridge</CardTitle>
{bridges.length > 0 && (
<Badge variant="secondary" className="text-xs">
{enabledCount} / {bridges.length} active
</Badge>
)}
</div>
<Button size="sm" onClick={openAdd} className="gap-1.5">
<Plus className="h-4 w-4" />
Add Bridge
</Button>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-10 text-center">On</TableHead>
<TableHead>Program</TableHead>
<TableHead className="w-36">Category</TableHead>
<TableHead>Executable Path</TableHead>
<TableHead>Arguments</TableHead>
<TableHead className="w-20 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bridges.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-gray-400 py-10">
<div className="flex flex-col items-center gap-2">
<Link2 className="h-8 w-8 text-gray-300" />
<span>No program bridges configured. Click "Add Bridge" to connect external software.</span>
</div>
</TableCell>
</TableRow>
) : (
bridges.map((bridge) => (
<TableRow key={bridge.id} className={!bridge.enabled ? "opacity-50" : ""}>
<TableCell className="text-center">
<Switch
checked={bridge.enabled}
onCheckedChange={() => toggleEnabled(bridge.id)}
className="scale-75"
/>
</TableCell>
<TableCell>
<div>
<p className="text-sm font-medium">{bridge.name}</p>
{bridge.notes && (
<p className="text-xs text-gray-400 truncate max-w-xs">{bridge.notes}</p>
)}
</div>
</TableCell>
<TableCell>
<Badge
className={`text-xs border ${CATEGORY_COLORS[bridge.category]}`}
variant="outline"
>
{CATEGORY_LABELS[bridge.category]}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600 font-mono truncate max-w-[200px]">
{bridge.executablePath || "—"}
</TableCell>
<TableCell className="text-sm text-gray-500 font-mono truncate max-w-[120px]">
{bridge.arguments || "—"}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => openEdit(bridge)}
>
<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(bridge.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Program Bridge</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
<div className="col-span-2 space-y-1">
<Label className="text-xs">Program Name</Label>
<Select
value={editing.name}
onValueChange={(v) => setEditing((d) => ({ ...d, name: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select or type program name..." />
</SelectTrigger>
<SelectContent>
{COMMON_PROGRAMS.map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectContent>
</Select>
{editing.name === "Custom" && (
<Input
className="h-9 text-sm mt-1"
placeholder="Enter custom program name"
value={editing.name === "Custom" ? "" : editing.name}
onChange={(e) => setEditing((d) => ({ ...d, name: e.target.value }))}
/>
)}
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Category</Label>
<Select
value={editing.category}
onValueChange={(v) => setEditing((d) => ({ ...d, category: v as BridgeCategory }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CATEGORY_LABELS) as BridgeCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Executable Path</Label>
<Input
placeholder="C:\Program Files\Software\app.exe"
value={editing.executablePath}
onChange={(e) => setEditing((d) => ({ ...d, executablePath: e.target.value }))}
className="h-9 text-sm font-mono"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Launch Arguments</Label>
<Input
placeholder="-patient {patientId} -mode bridge"
value={editing.arguments}
onChange={(e) => setEditing((d) => ({ ...d, arguments: e.target.value }))}
className="h-9 text-sm font-mono"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Notes</Label>
<Input
placeholder="Optional notes or version info"
value={editing.notes}
onChange={(e) => setEditing((d) => ({ ...d, notes: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="col-span-2 flex items-center gap-2">
<Switch
checked={editing.enabled}
onCheckedChange={(v) => setEditing((d) => ({ ...d, enabled: v }))}
/>
<Label className="text-sm cursor-pointer">
{editing.enabled ? "Enabled" : "Disabled"}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={!editing.name || editing.name === "Custom"}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,112 @@
import { useLocation } from "wouter";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TeethChart } from "@/components/chart/teeth-chart";
import { TreatmentPlanTab } from "@/components/chart/treatment-plan-tab";
import { PrescriptionTab } from "@/components/chart/prescription-tab";
import { LabManagementTab } from "@/components/chart/lab-management-tab";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, ListChecks, Pill, Microscope } from "lucide-react";
const SECTIONS = [
{ value: "charting", label: "Charting Map", icon: LayoutGrid },
{ value: "treatment-plan", label: "Treatment Plan", icon: ListChecks },
{ value: "prescription", label: "Prescription", icon: Pill },
{ value: "lab-management", label: "Lab Management", icon: Microscope },
] as const;
type Section = (typeof SECTIONS)[number]["value"];
function getSection(location: string): Section {
for (const s of SECTIONS) {
if (location.includes(s.value)) return s.value;
}
return "charting";
}
export default function ChartPage() {
const [location, navigate] = useLocation();
const activeTab = getSection(location);
return (
<div className="p-4 md:p-6 space-y-4">
<div>
<h1 className="text-xl font-semibold text-gray-900">Patient Chart</h1>
<p className="text-sm text-gray-500 mt-0.5">
Charting, treatment planning, prescriptions, and lab orders
</p>
</div>
<Tabs value={activeTab} onValueChange={(v) => navigate(`/chart/${v}`)}>
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4 h-auto gap-1 bg-gray-100 p-1 rounded-lg">
{SECTIONS.map(({ value, label, icon: Icon }) => (
<TabsTrigger
key={value}
value={value}
className="flex items-center gap-1.5 text-xs sm:text-sm py-2"
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">{label.split(" ")[0]}</span>
</TabsTrigger>
))}
</TabsList>
<TabsContent value="charting" className="mt-4">
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-base font-medium flex items-center gap-2">
<LayoutGrid className="h-4 w-4 text-primary" />
Charting Map
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<TeethChart />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="treatment-plan" className="mt-4">
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-base font-medium flex items-center gap-2">
<ListChecks className="h-4 w-4 text-primary" />
Treatment Plan
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<TreatmentPlanTab />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="prescription" className="mt-4">
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Pill className="h-4 w-4 text-primary" />
Prescription
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<PrescriptionTab />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="lab-management" className="mt-4">
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Microscope className="h-4 w-4 text-primary" />
Lab Management
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
<LabManagementTab />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -3,7 +3,19 @@ import { useMutation } from "@tanstack/react-query";
import { PatientTable } from "@/components/patients/patient-table";
import { AddPatientModal } from "@/components/patients/add-patient-modal";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw, FilePlus } from "lucide-react";
import {
Plus,
RefreshCw,
FilePlus,
CalendarPlus,
History,
HeartPulse,
FolderOpen,
Scan,
Camera,
NotebookPen,
FileCheck2,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import {
Card,
@@ -408,7 +420,7 @@ export default function PatientsPage() {
maxFileSizeMB={20}
/>
<div className="flex flex-col-2 gap-2 mt-4">
<div className="flex flex-wrap gap-2 mt-4">
<Button
className="w-full h-12 gap-2"
disabled={uploadedFiles.length === 0 || isExtracting}
@@ -461,6 +473,47 @@ export default function PatientsPage() {
)}
</Button>
</div>
{/* Patient action buttons */}
<div className="mt-5 pt-4 border-t border-gray-200">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Patient Actions
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<CalendarPlus className="h-4 w-4 text-blue-500 flex-shrink-0" />
New Appointment
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<History className="h-4 w-4 text-indigo-500 flex-shrink-0" />
Appointment History
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<HeartPulse className="h-4 w-4 text-rose-500 flex-shrink-0" />
Medical/Dental History
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<FolderOpen className="h-4 w-4 text-yellow-500 flex-shrink-0" />
Documents
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<Scan className="h-4 w-4 text-teal-500 flex-shrink-0" />
View Radiographs
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<Camera className="h-4 w-4 text-cyan-500 flex-shrink-0" />
Take New Radiographs
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<NotebookPen className="h-4 w-4 text-violet-500 flex-shrink-0" />
Clinic Notes
</Button>
<Button variant="outline" className="h-11 gap-2 text-sm justify-start" disabled>
<FileCheck2 className="h-4 w-4 text-orange-500 flex-shrink-0" />
New Claims
</Button>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -10,6 +10,7 @@ import { CredentialTable } from "@/components/settings/insuranceCredTable";
import { useAuth } from "@/hooks/use-auth";
import { Staff } from "@repo/db/types";
import { NpiProviderTable } from "@/components/settings/npiProviderTable";
import { ProgramBridgeTable } from "@/components/settings/program-bridge-table";
export default function SettingsPage() {
const { toast } = useToast();
@@ -496,6 +497,11 @@ export default function SettingsPage() {
<div className="mt-6">
<NpiProviderTable />
</div>
{/* Program Bridge Section */}
<div className="mt-6">
<ProgramBridgeTable />
</div>
</div>
);
}