diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 5eb94597..d4534d15 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -43,6 +43,7 @@ import shoppingVendorsRoutes from "./shopping-vendors"; import feeScheduleRoutes from "./feeSchedule"; import licenseRoutes from "./license"; import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa"; +import labRxRoutes from "./lab-rx"; const router = Router(); @@ -90,5 +91,6 @@ router.use("/commissions", commissionsRoutes); router.use("/shopping-vendors", shoppingVendorsRoutes); router.use("/fee-schedule", feeScheduleRoutes); router.use("/license", licenseRoutes); +router.use("/lab-rx", labRxRoutes); export default router; diff --git a/apps/Backend/src/routes/lab-rx.ts b/apps/Backend/src/routes/lab-rx.ts new file mode 100644 index 00000000..f28f5c61 --- /dev/null +++ b/apps/Backend/src/routes/lab-rx.ts @@ -0,0 +1,92 @@ +import express, { Request, Response } from "express"; +import { storage } from "../storage"; + +const router = express.Router(); + +router.get("/templates", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const templates = await storage.getLabRxTemplates(userId); + return res.json(templates); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch lab RX templates", details: String(err) }); + } +}); + +router.post("/templates", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { name, labName, labPhone, labFax, labAddress, labAccount, caseType, material, instructions } = req.body; + if (!name?.trim()) return res.status(400).json({ message: "Template name is required" }); + const template = await storage.createLabRxTemplate(userId, { + name: name.trim(), + labName: labName?.trim() || undefined, + labPhone: labPhone?.trim() || undefined, + labFax: labFax?.trim() || undefined, + labAddress: labAddress?.trim() || undefined, + labAccount: labAccount?.trim() || undefined, + caseType: caseType?.trim() || undefined, + material: material?.trim() || undefined, + instructions: instructions?.trim() || undefined, + }); + return res.status(201).json(template); + } catch (err) { + return res.status(500).json({ error: "Failed to create lab RX template", details: String(err) }); + } +}); + +router.put("/templates/:id", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const id = Number(req.params.id); + if (isNaN(id)) return res.status(400).json({ message: "Invalid ID" }); + const { name, labName, labPhone, labFax, labAddress, labAccount, caseType, material, instructions } = req.body; + if (name !== undefined && !name?.trim()) return res.status(400).json({ message: "Name cannot be empty" }); + const template = await storage.updateLabRxTemplate(userId, id, { + name: name?.trim(), + labName: labName?.trim() || undefined, + labPhone: labPhone?.trim() || undefined, + labFax: labFax?.trim() || undefined, + labAddress: labAddress?.trim() || undefined, + labAccount: labAccount?.trim() || undefined, + caseType: caseType?.trim() || undefined, + material: material?.trim() || undefined, + instructions: instructions?.trim() || undefined, + }); + return res.json(template); + } catch (err) { + return res.status(500).json({ error: "Failed to update lab RX template", details: String(err) }); + } +}); + +router.post("/templates/reorder", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { orderedIds } = req.body; + if (!Array.isArray(orderedIds)) return res.status(400).json({ message: "orderedIds must be an array" }); + await storage.reorderLabRxTemplates(userId, orderedIds); + return res.status(204).send(); + } catch (err) { + return res.status(500).json({ error: "Failed to reorder templates", details: String(err) }); + } +}); + +router.delete("/templates/:id", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const id = Number(req.params.id); + if (isNaN(id)) return res.status(400).json({ message: "Invalid ID" }); + const ok = await storage.deleteLabRxTemplate(userId, id); + if (!ok) return res.status(404).json({ message: "Template not found" }); + return res.status(204).send(); + } catch (err) { + return res.status(500).json({ error: "Failed to delete lab RX template", details: String(err) }); + } +}); + +export default router; diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 221c9fda..59e2901b 100755 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -25,6 +25,7 @@ import { procedureTimeslotStorage } from "./procedure-timeslot-storage"; import { insuranceContactStorage } from "./insurance-contact-storage"; import { commissionsStorage } from "./commissions-storage"; import { shoppingVendorStorage } from "./shopping-vendor-storage"; +import { labRxStorage } from "./lab-rx-storage"; export const storage = { @@ -53,6 +54,7 @@ export const storage = { ...insuranceContactStorage, ...commissionsStorage, ...shoppingVendorStorage, + ...labRxStorage, }; diff --git a/apps/Backend/src/storage/lab-rx-storage.ts b/apps/Backend/src/storage/lab-rx-storage.ts new file mode 100644 index 00000000..856b9be4 --- /dev/null +++ b/apps/Backend/src/storage/lab-rx-storage.ts @@ -0,0 +1,90 @@ +import { prisma as db } from "@repo/db/client"; + +export type LabRxTemplateInput = { + name: string; + labName?: string; + labPhone?: string; + labFax?: string; + labAddress?: string; + labAccount?: string; + caseType?: string; + material?: string; + instructions?: string; +}; + +const DEFAULT_TEMPLATES: LabRxTemplateInput[] = [ + { + name: "Highland Dental Crown", + labName: "Highland Dental Studio", + labPhone: "781-552-5198", + labAddress: "87 Cambridge Street, Burlington, MA 01803", + caseType: "Crown", + material: "Zirconia", + }, + { + name: "Highland Dental Full Denture", + labName: "Highland Dental Studio", + labPhone: "781-552-5198", + labAddress: "87 Cambridge Street, Burlington, MA 01803", + caseType: "Full Denture", + }, + { + name: "Highland Dental Partial Denture", + labName: "Highland Dental Studio", + labPhone: "781-552-5198", + labAddress: "87 Cambridge Street, Burlington, MA 01803", + caseType: "Partial Denture", + }, +]; + +export const labRxStorage = { + async getLabRxTemplates(userId: number) { + const existing = await db.labRxTemplate.findMany({ + where: { userId }, + orderBy: { sortOrder: "asc" }, + }); + + if (existing.length === 0) { + await db.labRxTemplate.createMany({ + data: DEFAULT_TEMPLATES.map((t, index) => ({ userId, sortOrder: index, ...t })), + }); + return db.labRxTemplate.findMany({ + where: { userId }, + orderBy: { sortOrder: "asc" }, + }); + } + + return existing; + }, + + async reorderLabRxTemplates(userId: number, orderedIds: number[]) { + await db.$transaction( + orderedIds.map((id, index) => + db.labRxTemplate.updateMany({ + where: { id, userId }, + data: { sortOrder: index }, + }) + ) + ); + }, + + async createLabRxTemplate(userId: number, data: LabRxTemplateInput) { + return db.labRxTemplate.create({ + data: { userId, ...data }, + }); + }, + + async updateLabRxTemplate(userId: number, id: number, data: Partial) { + return db.labRxTemplate.update({ + where: { id, userId }, + data, + }); + }, + + async deleteLabRxTemplate(userId: number, id: number) { + const existing = await db.labRxTemplate.findFirst({ where: { id, userId } }); + if (!existing) return false; + await db.labRxTemplate.delete({ where: { id } }); + return true; + }, +}; diff --git a/apps/Frontend/src/components/chart/lab-management-tab.tsx b/apps/Frontend/src/components/chart/lab-management-tab.tsx index 3637bfb9..59314f43 100644 --- a/apps/Frontend/src/components/chart/lab-management-tab.tsx +++ b/apps/Frontend/src/components/chart/lab-management-tab.tsx @@ -3,12 +3,14 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { PatientTable } from "@/components/patients/patient-table"; import { BarcodeModal } from "@/components/chart/barcode-modal"; +import { LabRxModal } from "@/components/chart/lab-rx-modal"; import { Barcode, FileText } from "lucide-react"; import { Patient } from "@repo/db/types"; export function LabManagementTab() { const [selectedPatient, setSelectedPatient] = useState(null); const [barcodeOpen, setBarcodeOpen] = useState(false); + const [labRxOpen, setLabRxOpen] = useState(false); return (
@@ -33,7 +35,12 @@ export function LabManagementTab() { Create a Barcode - @@ -59,11 +66,18 @@ export function LabManagementTab() { {selectedPatient && ( - setBarcodeOpen(false)} - patient={selectedPatient} - /> + <> + setBarcodeOpen(false)} + patient={selectedPatient} + /> + setLabRxOpen(false)} + patient={selectedPatient} + /> + )}
); diff --git a/apps/Frontend/src/components/chart/lab-rx-modal.tsx b/apps/Frontend/src/components/chart/lab-rx-modal.tsx new file mode 100644 index 00000000..e45dd487 --- /dev/null +++ b/apps/Frontend/src/components/chart/lab-rx-modal.tsx @@ -0,0 +1,640 @@ +import { useState, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; +import { Patient } from "@repo/db/types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Plus, Pencil, Trash2, Printer, X, Check, FlaskConical, ChevronUp, ChevronDown } from "lucide-react"; + +type LabRxTemplate = { + id: number; + name: string; + labName?: string | null; + labPhone?: string | null; + labFax?: string | null; + labAddress?: string | null; + labAccount?: string | null; + caseType?: string | null; + material?: string | null; + instructions?: string | null; + sortOrder: number; +}; + +type TemplateFormState = { + name: string; + labName: string; + labPhone: string; + labFax: string; + labAddress: string; + labAccount: string; + caseType: string; + material: string; + instructions: string; +}; + +type RxFields = { + toothNumbers: string; + shade: string; + dueDate: string; + doctorName: string; + additionalNotes: string; +}; + +const EMPTY_TEMPLATE: TemplateFormState = { + name: "", labName: "", labPhone: "", labFax: "", + labAddress: "", labAccount: "", caseType: "", material: "", instructions: "Please make\nThank you!", +}; +const EMPTY_RX: RxFields = { toothNumbers: "", shade: "", dueDate: "", doctorName: "", additionalNotes: "Please make\nThank you!" }; + +const CASE_TYPES = ["Crown", "Bridge", "Veneer", "Inlay/Onlay", "Full Denture", "Partial Denture", "Night Guard", "Retainer", "Implant Crown", "Other"]; +const MATERIALS = ["Zirconia", "PFM (Porcelain-Fused-to-Metal)", "E-Max", "Acrylic", "Cast Metal", "Composite", "PMMA", "Other"]; + +interface Props { + open: boolean; + onClose: () => void; + patient: Patient; +} + +export function LabRxModal({ open, onClose, patient }: Props) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const printRef = useRef(null); + + // RX popup + const [rxTemplate, setRxTemplate] = useState(null); + const [rx, setRx] = useState(EMPTY_RX); + + // Inline rename (Tab 1 cards) + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + + // Manage Templates form + const [showTemplateForm, setShowTemplateForm] = useState(false); + const [editingTemplateId, setEditingTemplateId] = useState(null); + const [templateForm, setTemplateForm] = useState(EMPTY_TEMPLATE); + + const { data: providers = [] } = useQuery<{ id: number; providerName: string }[]>({ + queryKey: ["/api/npiProviders"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/npiProviders"); + if (!res.ok) throw new Error("Failed to fetch providers"); + return res.json(); + }, + enabled: open, + }); + + const { data: templates = [], isLoading } = useQuery({ + queryKey: ["/api/lab-rx/templates"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/lab-rx/templates"); + if (!res.ok) throw new Error("Failed to fetch"); + return res.json(); + }, + enabled: open, + }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["/api/lab-rx/templates"] }); + + const createMutation = useMutation({ + mutationFn: async (data: TemplateFormState) => { + const res = await apiRequest("POST", "/api/lab-rx/templates", data); + if (!res.ok) { const e = await res.json().catch(() => null); throw new Error(e?.message || "Failed to save"); } + return res.json(); + }, + onSuccess: () => { invalidate(); setShowTemplateForm(false); setTemplateForm(EMPTY_TEMPLATE); toast({ title: "Template saved" }); }, + onError: (e: any) => toast({ title: "Error", description: e.message, variant: "destructive" }), + }); + + const updateMutation = useMutation({ + mutationFn: async ({ id, data }: { id: number; data: Partial }) => { + const res = await apiRequest("PUT", `/api/lab-rx/templates/${id}`, data); + if (!res.ok) { const e = await res.json().catch(() => null); throw new Error(e?.message || "Failed to update"); } + return res.json(); + }, + onSuccess: () => { invalidate(); setEditingTemplateId(null); setTemplateForm(EMPTY_TEMPLATE); setShowTemplateForm(false); toast({ title: "Template updated" }); }, + onError: (e: any) => toast({ title: "Error", description: e.message, variant: "destructive" }), + }); + + const renameMutation = useMutation({ + mutationFn: async ({ id, name }: { id: number; name: string }) => { + const res = await apiRequest("PUT", `/api/lab-rx/templates/${id}`, { name }); + if (!res.ok) { const e = await res.json().catch(() => null); throw new Error(e?.message || "Failed to rename"); } + return res.json(); + }, + onSuccess: () => { invalidate(); setRenamingId(null); }, + onError: (e: any) => toast({ title: "Error", description: e.message, variant: "destructive" }), + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + const res = await apiRequest("DELETE", `/api/lab-rx/templates/${id}`); + if (!res.ok) throw new Error("Failed to delete"); + }, + onSuccess: () => { invalidate(); toast({ title: "Template deleted" }); }, + onError: (e: any) => toast({ title: "Error", description: e.message, variant: "destructive" }), + }); + + const reorderMutation = useMutation({ + mutationFn: async (orderedIds: number[]) => { + const res = await apiRequest("POST", "/api/lab-rx/templates/reorder", { orderedIds }); + if (!res.ok) throw new Error("Failed to reorder"); + }, + onSuccess: () => invalidate(), + onError: (e: any) => toast({ title: "Error", description: e.message, variant: "destructive" }), + }); + + const moveTemplate = (index: number, direction: "up" | "down") => { + const swapIndex = direction === "up" ? index - 1 : index + 1; + const reordered = [...templates]; + const a = reordered[index]!; + const b = reordered[swapIndex]!; + reordered[index] = b; + reordered[swapIndex] = a; + queryClient.setQueryData(["/api/lab-rx/templates"], reordered); + reorderMutation.mutate(reordered.map((t) => t.id)); + }; + + const openRx = (t: LabRxTemplate) => { + if (renamingId !== null) return; + setRxTemplate(t); + setRx(EMPTY_RX); + }; + + const startRename = (e: React.MouseEvent, t: LabRxTemplate) => { + e.stopPropagation(); + setRenamingId(t.id); + setRenameValue(t.name); + }; + + const commitRename = () => { + if (!renameValue.trim()) { toast({ title: "Name cannot be empty", variant: "destructive" }); return; } + if (renamingId !== null) renameMutation.mutate({ id: renamingId, name: renameValue.trim() }); + }; + + const cancelRename = () => setRenamingId(null); + + const openAddTemplate = () => { setEditingTemplateId(null); setTemplateForm(EMPTY_TEMPLATE); setShowTemplateForm(true); }; + const openEditTemplate = (t: LabRxTemplate) => { + setEditingTemplateId(t.id); + setTemplateForm({ name: t.name, labName: t.labName ?? "", labPhone: t.labPhone ?? "", labFax: t.labFax ?? "", labAddress: t.labAddress ?? "", labAccount: t.labAccount ?? "", caseType: t.caseType ?? "", material: t.material ?? "", instructions: t.instructions ?? "" }); + setShowTemplateForm(true); + }; + const handleSaveTemplate = () => { + if (!templateForm.name.trim()) { toast({ title: "Template name is required", variant: "destructive" }); return; } + if (editingTemplateId !== null) updateMutation.mutate({ id: editingTemplateId, data: templateForm }); + else createMutation.mutate(templateForm); + }; + const isSaving = createMutation.isPending || updateMutation.isPending; + + const handlePrint = () => { + const content = printRef.current; + if (!content || !rxTemplate) return; + const win = window.open("", "_blank", "width=800,height=900"); + if (!win) return; + win.document.write(`Lab RX + + ${content.innerHTML}`); + win.document.close(); + win.focus(); + setTimeout(() => { win.print(); win.close(); }, 300); + }; + + const patientName = `${patient.firstName} ${patient.lastName}`; + const today = new Date().toLocaleDateString("en-US"); + + return ( + <> + {/* ── Main modal ── */} + !v && onClose()}> + + + + + Lab RX — {patientName} + + + + + + Create Lab RX + Manage Templates + + + {/* ── Tab 1: pick template ── */} + +

+ Click a template to create a Lab RX +

+ {isLoading ? ( +

Loading templates…

+ ) : templates.length === 0 ? ( +

No templates yet. Go to "Manage Templates" to add one.

+ ) : ( +
+ {templates.map((t) => ( +
openRx(t)} + className="group relative border rounded-lg p-3 bg-white cursor-pointer hover:border-blue-400 hover:shadow-sm transition-all" + > + {renamingId === t.id ? ( +
e.stopPropagation()}> + setRenameValue(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") commitRename(); if (e.key === "Escape") cancelRename(); }} + className="h-7 text-sm font-medium" + /> + + +
+ ) : ( +
+
+

{t.name}

+ {t.labName &&

{t.labName}

} +
+ {t.caseType && {t.caseType}} + {t.material && {t.material}} +
+
+ +
+ )} +
+ ))} +
+ )} +
+ + {/* ── Tab 2: manage templates ── */} + +
+

{templates.length} template{templates.length !== 1 ? "s" : ""}

+ +
+ + {showTemplateForm && ( +
+
+

+ {editingTemplateId !== null ? "Edit Template" : "New Template"} +

+
+
+ + {/* Template Name section */} +
+
+

Template Name

+
+
+
+ + setTemplateForm((f) => ({ ...f, name: e.target.value }))} + className="h-9 text-sm" + /> +

This name appears on the template picker button.

+
+
+ + {/* Lab Information section */} +
+
+

Lab Information

+
+
+
+
+ + setTemplateForm((f) => ({ ...f, labName: e.target.value }))} className="h-9 text-sm" /> +
+
+ + setTemplateForm((f) => ({ ...f, labAccount: e.target.value }))} className="h-9 text-sm" /> +
+
+ + setTemplateForm((f) => ({ ...f, labPhone: e.target.value }))} className="h-9 text-sm" /> +
+
+ + setTemplateForm((f) => ({ ...f, labFax: e.target.value }))} className="h-9 text-sm" /> +
+
+ + setTemplateForm((f) => ({ ...f, labAddress: e.target.value }))} className="h-9 text-sm" /> +
+
+
+ + {/* Default Case Details section */} +
+
+

Default Case Details

+
+
+
+
+ + +
+
+ + +
+
+ +