feat: add Chart section, colorize sidebar icons, rename nav items, patient action buttons, program bridge
This commit is contained in:
@@ -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
|
||||
|
||||
364
apps/Frontend/src/components/chart/lab-management-tab.tsx
Normal file
364
apps/Frontend/src/components/chart/lab-management-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
324
apps/Frontend/src/components/chart/prescription-tab.tsx
Normal file
324
apps/Frontend/src/components/chart/prescription-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
apps/Frontend/src/components/chart/teeth-chart.tsx
Normal file
253
apps/Frontend/src/components/chart/teeth-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
apps/Frontend/src/components/chart/treatment-plan-tab.tsx
Normal file
291
apps/Frontend/src/components/chart/treatment-plan-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
336
apps/Frontend/src/components/settings/program-bridge-table.tsx
Normal file
336
apps/Frontend/src/components/settings/program-bridge-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
apps/Frontend/src/pages/chart-page.tsx
Normal file
112
apps/Frontend/src/pages/chart-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user