feat: add new frontend components, MH batch worker, and gitignore rules

- Add all new Frontend source files (pages, components, hooks, utils)
- Add selenium_MHBatchPaymentCheckWorker.py and MHSinglePaymentCheckWorker.py
- Add install-steps-5-13.sh setup script
- Update .gitignore to exclude runtime/sensitive data (backups, uploads,
  chat-history, keys, downloads, generated .d.ts files) while keeping folders
- Add .gitkeep to preserve empty runtime folders in git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:23:43 -04:00
parent b7e06adf2f
commit 1edf73fdc8
173 changed files with 33469 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip, } from "recharts";
export function AppointmentsByDay({ appointments }) {
const daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const countsByDay = daysOfWeek.map((day) => ({ day, count: 0 }));
// Get current date and set time to start of day (midnight)
const now = new Date();
now.setHours(0, 0, 0, 0);
// Calculate Monday of the current week
const day = now.getDay(); // 0 = Sunday, 1 = Monday, ...
const diffToMonday = day === 0 ? -6 : 1 - day; // adjust if Sunday
const monday = new Date(now);
monday.setDate(now.getDate() + diffToMonday);
// Sunday of the current week
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
// Filter appointments only from this week (Monday to Sunday)
const appointmentsThisWeek = appointments.filter((appointment) => {
if (!appointment.date)
return false;
const date = new Date(appointment.date);
// Reset time to compare just the date
date.setHours(0, 0, 0, 0);
return date >= monday && date <= sunday;
});
// Count appointments by day for current week
appointmentsThisWeek.forEach((appointment) => {
const date = new Date(appointment.date);
const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, ...
const dayIndex = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // Monday=0, Sunday=6
if (countsByDay[dayIndex]) {
countsByDay[dayIndex].count += 1;
}
});
return (<Card className="shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">
Appointments by Day
</CardTitle>
<p className="text-xs text-muted-foreground">
Distribution of appointments throughout the week
</p>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={countsByDay} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
<XAxis dataKey="day" fontSize={12} tickLine={false} axisLine={false}/>
<YAxis fontSize={12} tickLine={false} axisLine={false}/>
<Tooltip formatter={(value) => [`${value} appointments`, "Count"]} labelFormatter={(value) => `${value}`}/>
<Bar dataKey="count" fill="#2563eb" radius={[4, 4, 0, 0]}/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,47 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
export function NewPatients({ patients }) {
// Get months for the chart
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
// Process patient data by registration month
const patientsByMonth = months.map(month => ({ name: month, count: 0 }));
// Count new patients by month
patients.forEach(patient => {
const createdDate = new Date(patient.createdAt);
const monthIndex = createdDate.getMonth();
if (patientsByMonth[monthIndex]) {
patientsByMonth[monthIndex].count += 1;
}
});
// Add some sample data for visual effect if no patients
if (patients.length === 0) {
// Sample data pattern similar to the screenshot
const sampleData = [17, 12, 22, 16, 15, 17, 22, 28, 20, 16];
sampleData.forEach((value, index) => {
if (index < patientsByMonth.length) {
if (patientsByMonth[index]) {
patientsByMonth[index].count = value;
}
}
});
}
return (<Card className="shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base font-medium">New Patients</CardTitle>
<p className="text-xs text-muted-foreground">Monthly trend of new patient registrations</p>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={patientsByMonth} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
<XAxis dataKey="name" fontSize={12} tickLine={false} axisLine={false}/>
<YAxis fontSize={12} tickLine={false} axisLine={false}/>
<Tooltip formatter={(value) => [`${value} patients`, "Count"]} labelFormatter={(value) => `${value}`}/>
<Line type="monotone" dataKey="count" stroke="#f97316" strokeWidth={2} dot={{ r: 4, strokeWidth: 2 }} activeDot={{ r: 6, strokeWidth: 2 }}/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,417 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Trash2, Plus, Save, X } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
import { findPriceMismatches, } from "@/utils/procedureCombosMapping";
import { useLocation } from "wouter";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import { DirectComboButtons, RegularComboButtons, } from "@/components/procedure/procedure-combo-buttons";
export function AppointmentProceduresDialog({ open, onOpenChange, appointmentId, patientId, patient, serviceDate, }) {
const { toast } = useToast();
const [, setLocation] = useLocation();
// NPI provider state — stored per-appointment on the procedure rows
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState(null);
const emptyRow = () => ({ code: "", label: "", fee: "", tooth: "", surface: "" });
const [pendingRows, setPendingRows] = useState([emptyRow(), emptyRow(), emptyRow()]);
// reset pending rows when dialog opens
useEffect(() => {
if (open)
setPendingRows([emptyRow(), emptyRow(), emptyRow()]);
}, [open]);
// inline edit state
const [editingId, setEditingId] = useState(null);
const [editRow, setEditRow] = useState({});
const [clearAllOpen, setClearAllOpen] = useState(false);
// price mismatch dialog
const [priceMismatches, setPriceMismatches] = useState([]);
const pendingAction = useRef(null);
const deriveInsuranceSiteKey = (provider) => {
const p = (provider || "").toLowerCase().trim();
if (!p)
return "";
if (p.includes("masshealth") || p === "mh" || p === "mass health")
return "MH";
if (p.includes("commonwealth care alliance") || p === "cca")
return "CCA";
if (p.includes("ddma") || p.includes("delta dental ma"))
return "DDMA";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco")
return "TuftsSCO";
if ((p.includes("united") && p.includes("sco")) || p === "unitedsco")
return "UnitedSCO";
return "";
};
const runWithPriceCheck = (procedureCode, fee, action) => {
const siteKey = deriveInsuranceSiteKey(patient?.insuranceProvider);
if (!siteKey || !procedureCode.trim() || !fee) {
action();
return;
}
const mismatches = findPriceMismatches([{ procedureCode, totalBilled: fee, procedureDate: "" }], siteKey, patient?.dateOfBirth || "", serviceDate ?? new Date().toISOString().slice(0, 10));
if (mismatches.length === 0) {
action();
}
else {
pendingAction.current = action;
setPriceMismatches(mismatches);
}
};
const savePricesToSchedule = async (mismatches) => {
const siteKey = deriveInsuranceSiteKey(patient?.insuranceProvider);
await Promise.all(mismatches.map(m => apiRequest("POST", "/api/fee-schedule/update-price", {
siteKey,
procedureCode: m.procedureCode,
price: m.enteredPrice,
})));
};
// ── NPI Providers ──────────────────────────────────────────────
const { data: npiProviders = [] } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
if (!res.ok)
throw new Error("Failed to fetch NPI providers");
return res.json();
},
enabled: open,
});
// ── Procedures ─────────────────────────────────────────────────
const { data: procedures = [], isLoading } = useQuery({
queryKey: ["appointment-procedures", appointmentId],
queryFn: async () => {
const res = await apiRequest("GET", `/api/appointment-procedures/${appointmentId}`);
if (!res.ok)
throw new Error("Failed to load procedures");
return res.json();
},
enabled: open && !!appointmentId,
});
// Sync NPI provider from saved procedures when they load
useEffect(() => {
if (!procedures.length)
return;
const saved = procedures[0]?.npiProviderId ?? null;
if (saved != null)
setSelectedNpiProviderId(Number(saved));
}, [procedures]);
// Default NPI provider to Mary Scannell / first when none saved yet
useEffect(() => {
if (selectedNpiProviderId != null || !npiProviders.length)
return;
const mary = npiProviders.find((p) => p.providerName.toLowerCase() === "mary scannell");
setSelectedNpiProviderId((mary ?? npiProviders[0])?.id ?? null);
}, [npiProviders, selectedNpiProviderId]);
// ── Mutations ──────────────────────────────────────────────────
const setNpiMutation = useMutation({
mutationFn: async (npiProviderId) => {
const res = await apiRequest("PUT", `/api/appointment-procedures/set-npi-provider/${appointmentId}`, { npiProviderId });
if (!res.ok)
throw new Error("Failed to update provider");
},
onSuccess: () => {
toast({ title: "Rendering provider saved" });
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
},
onError: (err) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
const bulkAddMutation = useMutation({
mutationFn: async (rows) => {
const res = await apiRequest("POST", "/api/appointment-procedures/bulk", rows);
if (!res.ok)
throw new Error("Failed to add procedures");
return res.json();
},
onSuccess: () => {
toast({ title: "Procedures saved" });
setPendingRows([emptyRow(), emptyRow(), emptyRow()]);
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
},
});
const deleteMutation = useMutation({
mutationFn: async (id) => {
const res = await apiRequest("DELETE", `/api/appointment-procedures/${id}`);
if (!res.ok)
throw new Error("Failed to delete");
},
onSuccess: () => {
toast({ title: "Deleted" });
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
},
});
const clearAllMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("DELETE", `/api/appointment-procedures/clear/${appointmentId}`);
if (!res.ok)
throw new Error("Failed to clear procedures");
},
onSuccess: () => {
toast({ title: "All procedures cleared" });
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
setClearAllOpen(false);
},
onError: (err) => {
toast({ title: "Error", description: err.message ?? "Failed to clear procedures", variant: "destructive" });
},
});
const updateMutation = useMutation({
mutationFn: async () => {
if (!editingId)
return;
const res = await apiRequest("PUT", `/api/appointment-procedures/${editingId}`, editRow);
if (!res.ok)
throw new Error("Failed to update");
return res.json();
},
onSuccess: () => {
toast({ title: "Updated" });
setEditingId(null);
setEditRow({});
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
},
});
// ── Handlers ───────────────────────────────────────────────────
const handleAddCombo = (comboKey) => {
const combo = PROCEDURE_COMBOS[comboKey];
if (!combo)
return;
const rows = combo.codes.map((code, idx) => ({
appointmentId,
patientId,
npiProviderId: selectedNpiProviderId ?? null,
procedureCode: code,
procedureLabel: combo.label,
fee: 0,
source: "COMBO",
comboKey,
toothNumber: combo.toothNumbers?.[idx] ?? null,
}));
bulkAddMutation.mutate(rows);
};
const startEdit = (row) => {
if (!row.id)
return;
setEditingId(row.id);
setEditRow({
procedureCode: row.procedureCode,
procedureLabel: row.procedureLabel,
fee: row.fee,
toothNumber: row.toothNumber,
toothSurface: row.toothSurface,
});
};
const cancelEdit = () => { setEditingId(null); setEditRow({}); };
const handleSavePendingRows = () => {
const rows = pendingRows
.filter((r) => r.code.trim())
.map((r) => ({
appointmentId,
patientId,
npiProviderId: selectedNpiProviderId ?? null,
procedureCode: r.code.trim().toUpperCase(),
procedureLabel: r.label || null,
fee: r.fee ? Number(r.fee) : 0,
toothNumber: r.tooth || null,
toothSurface: r.surface || null,
source: "MANUAL",
}));
if (!rows.length)
return;
bulkAddMutation.mutate(rows);
};
const handleDirectClaim = () => {
setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`);
onOpenChange(false);
};
const handleManualClaim = () => {
setLocation(`/claims?appointmentId=${appointmentId}&mode=manual`);
onOpenChange(false);
};
const selectedProvider = npiProviders.find((p) => p.id === selectedNpiProviderId);
// ── UI ─────────────────────────────────────────────────────────
return (<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto pointer-events-none" onPointerDownOutside={(e) => { if (clearAllOpen)
e.preventDefault(); }} onInteractOutside={(e) => { if (clearAllOpen)
e.preventDefault(); }}>
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
Appointment Procedures
{serviceDate && <span className="ml-3 text-base font-normal text-muted-foreground">{serviceDate}</span>}
</DialogTitle>
</DialogHeader>
{/* ── Rendering Provider ─────────────────────────────── */}
<div className="flex items-end gap-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex-1">
<Label className="text-sm font-medium text-blue-800">Rendering Provider (NPI)</Label>
<Select value={selectedNpiProviderId?.toString() ?? ""} onValueChange={(v) => setSelectedNpiProviderId(v ? Number(v) : null)}>
<SelectTrigger className="mt-1 bg-white">
<SelectValue placeholder="Select NPI Provider"/>
</SelectTrigger>
<SelectContent>
{npiProviders.map((p) => (<SelectItem key={p.id} value={String(p.id)}>
{p.npiNumber} {p.providerName}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<Button size="sm" className="mb-0.5" onClick={() => setNpiMutation.mutate(selectedNpiProviderId)} disabled={setNpiMutation.isPending || !procedures.length}>
<Save className="h-4 w-4 mr-1"/>
Set for All
</Button>
{selectedProvider && (<span className="text-sm text-blue-700 mb-1 whitespace-nowrap">
{selectedProvider.providerName}
</span>)}
</div>
{/* ── Combos ─────────────────────────────────────────── */}
<div className="space-y-8 pointer-events-auto">
<DirectComboButtons onDirectCombo={handleAddCombo}/>
<RegularComboButtons onRegularCombo={handleAddCombo}/>
</div>
{/* ── Pending Lines ───────────────────────────────────── */}
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-2">
<div className="font-medium text-sm mb-3">Add Procedures</div>
{/* Column headers */}
<div className="grid grid-cols-[100px_1fr_90px_80px_80px_36px] gap-2 px-1 text-xs font-semibold text-muted-foreground">
<div>Code</div><div>Label</div><div>Fee</div><div>Tooth</div><div>Surface</div><div />
</div>
{pendingRows.map((row, i) => (<div key={i} className="grid grid-cols-[100px_1fr_90px_80px_80px_36px] gap-2 items-center">
<Input placeholder="D0120" value={row.code} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, code: e.target.value } : r))}/>
<Input placeholder="Exam" value={row.label} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, label: e.target.value } : r))}/>
<Input type="number" placeholder="0.00" value={row.fee} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, fee: e.target.value } : r))}/>
<Input placeholder="14" value={row.tooth} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, tooth: e.target.value } : r))}/>
<Input placeholder="MO" value={row.surface} onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, surface: e.target.value } : r))}/>
<button type="button" className="p-1 rounded hover:bg-red-50" onClick={() => setPendingRows((prev) => prev.filter((_, idx) => idx !== i))}>
<Trash2 className="h-4 w-4 text-red-400 hover:text-red-600"/>
</button>
</div>))}
<div className="flex justify-between items-center pt-2">
<Button size="sm" variant="outline" onClick={() => setPendingRows((prev) => [...prev, emptyRow()])}>
<Plus className="h-4 w-4 mr-1"/>
Add Line
</Button>
<Button size="sm" onClick={handleSavePendingRows} disabled={!pendingRows.some((r) => r.code.trim()) || bulkAddMutation.isPending}>
<Save className="h-4 w-4 mr-1"/>
Save Lines
</Button>
</div>
</div>
{/* ── Procedures List ─────────────────────────────────── */}
<div className="mt-8 space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">Saved Procedures ({procedures.length})</div>
<Button variant="destructive" size="sm" disabled={!procedures.length} onClick={() => setClearAllOpen(true)}>
Clear All
</Button>
</div>
<div className="border rounded-lg divide-y bg-white">
<div className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted/40">
<div>Code</div><div>Label</div><div>Fee</div><div>Tooth</div><div>Surface</div>
<div className="text-center">Edit</div><div className="text-center">Delete</div>
</div>
{isLoading && <div className="p-4 text-sm text-muted-foreground">Loading...</div>}
{!isLoading && procedures.length === 0 && (<div className="p-4 text-sm text-muted-foreground">No procedures added yet</div>)}
{procedures.map((p) => (<div key={p.id} className="grid grid-cols-[90px_1fr_90px_80px_80px_72px_72px] gap-2 px-3 py-3 text-sm hover:bg-muted/40 transition">
{editingId === p.id ? (<>
<Input className="w-[90px]" value={editRow.procedureCode ?? ""} onChange={(e) => setEditRow({ ...editRow, procedureCode: e.target.value })}/>
<Input className="flex-1" value={editRow.procedureLabel ?? ""} onChange={(e) => setEditRow({ ...editRow, procedureLabel: e.target.value })}/>
<Input className="w-[90px]" value={editRow.fee !== undefined && editRow.fee !== null ? String(editRow.fee) : ""} onChange={(e) => setEditRow({ ...editRow, fee: Number(e.target.value) })}/>
<Input className="w-[80px]" value={editRow.toothNumber ?? ""} onChange={(e) => setEditRow({ ...editRow, toothNumber: e.target.value })}/>
<Input className="w-[80px]" value={editRow.toothSurface ?? ""} onChange={(e) => setEditRow({ ...editRow, toothSurface: e.target.value })}/>
<div className="flex justify-center">
<Button size="icon" variant="ghost" onClick={() => runWithPriceCheck(editRow.procedureCode || "", Number(editRow.fee), () => updateMutation.mutate())}><Save className="h-4 w-4"/></Button>
</div>
<div className="flex justify-center">
<Button size="icon" variant="ghost" onClick={cancelEdit}><X className="h-4 w-4"/></Button>
</div>
</>) : (<>
<div className="w-[90px] font-medium">{p.procedureCode}</div>
<div className="flex-1 text-muted-foreground">{p.procedureLabel}</div>
<div className="w-[90px]">{p.fee !== null && p.fee !== undefined ? String(p.fee) : ""}</div>
<div className="w-[80px]">{p.toothNumber}</div>
<div className="w-[80px]">{p.toothSurface}</div>
<div className="flex justify-center">
<Button size="icon" variant="ghost" onClick={() => startEdit(p)}>Edit</Button>
</div>
<div className="flex justify-center">
<Button size="icon" variant="ghost" onClick={() => deleteMutation.mutate(p.id)}>
<Trash2 className="h-4 w-4 text-red-500"/>
</Button>
</div>
</>)}
</div>))}
</div>
</div>
{/* ── Footer ─────────────────────────────────────────── */}
<div className="flex justify-between items-center gap-2 mt-8 pt-4 border-t">
<div className="flex gap-2">
<Button className="bg-green-600 hover:bg-green-700" disabled={!procedures.length} onClick={handleDirectClaim}>
Direct Claim
</Button>
<Button variant="outline" className="border-blue-500 text-blue-600 hover:bg-blue-50" disabled={!procedures.length} onClick={handleManualClaim}>
Manual Claim
</Button>
</div>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
</div>
</DialogContent>
<DeleteConfirmationDialog isOpen={clearAllOpen} entityName="all procedures for this appointment" onCancel={() => setClearAllOpen(false)} onConfirm={() => { setClearAllOpen(false); clearAllMutation.mutate(); }}/>
{/* Price mismatch dialog */}
<AlertDialog open={priceMismatches.length > 0} onOpenChange={open => { if (!open)
setPriceMismatches([]); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Save new price to the app?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>The following procedure prices differ from the fee schedule:</p>
<ul className="text-sm space-y-1">
{priceMismatches.map(m => (<li key={m.procedureCode} className="flex justify-between gap-4">
<span className="font-medium">{m.procedureCode}</span>
<span className="text-muted-foreground">Schedule: ${m.schedulePrice.toFixed(2)}</span>
<span className="text-foreground font-semibold">Entered: ${m.enteredPrice.toFixed(2)}</span>
</li>))}
</ul>
<p className="text-sm">Do you want to save the new price(s) to the fee schedule for future use?</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setPriceMismatches([]);
pendingAction.current?.();
pendingAction.current = null;
}}>
No
</AlertDialogCancel>
<AlertDialogAction onClick={async () => {
await savePricesToSchedule(priceMismatches);
setPriceMismatches([]);
pendingAction.current?.();
pendingAction.current = null;
}}>
Yes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>);
}

View File

@@ -0,0 +1,19 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { AppointmentForm } from "./appointment-form";
export function AddAppointmentModal({ open, onOpenChange, onSubmit, onDelete, isLoading, appointment, prefillData, }) {
return (<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{appointment ? "Edit Appointment" : "Add New Appointment"}
</DialogTitle>
</DialogHeader>
<div className="p-1">
<AppointmentForm appointment={appointment} prefillData={prefillData} onSubmit={(data) => {
onSubmit(data);
onOpenChange(false);
}} isLoading={isLoading} onDelete={onDelete} onOpenChange={onOpenChange}/>
</div>
</DialogContent>
</Dialog>);
}

View File

@@ -0,0 +1,434 @@
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { apiRequest } from "@/lib/queryClient";
import { APPOINTMENT_TYPES } from "@/utils/appointmentTypeUtils";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Clock } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useDebounce } from "use-debounce";
import { insertAppointmentSchema, } from "@repo/db/types";
import { DateInputField } from "@/components/ui/dateInputField";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { toast } from "@/hooks/use-toast";
export function AppointmentForm({ appointment, prefillData, onSubmit, onDelete, onOpenChange, isLoading = false, }) {
const { user } = useAuth();
const inputRef = useRef(null);
const [prefillPatient, setPrefillPatient] = useState(null);
const [otherTypeDesc, setOtherTypeDesc] = useState(() => {
const t = appointment?.type ?? "";
return t.startsWith("other:") ? t.slice(6) : "";
});
// Track whether the user explicitly changed the type during this edit session.
// Used to set typeLocked so the auto-sync won't overwrite a deliberate choice.
const originalType = useRef(appointment?.type ?? "");
const [typeChangedByUser, setTypeChangedByUser] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
inputRef.current?.focus();
}, 50); // small delay ensures content is mounted
return () => clearTimeout(timeout);
}, []);
const { data: staffMembersRaw = [] } = useQuery({
queryKey: ["/api/staffs/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/staffs/");
return res.json();
},
enabled: !!user,
});
const colorMap = {
"Dr. Kai Gao": "bg-blue-600",
"Dr. Jane Smith": "bg-emerald-600",
};
const staffMembers = staffMembersRaw.map((staff) => ({
...staff,
color: colorMap[staff.name] || "bg-gray-400",
}));
// Format the date and times for the form
const defaultValues = appointment
? {
userId: user?.id,
patientId: appointment.patientId,
title: appointment.title,
date: parseLocalDate(appointment.date),
startTime: appointment.startTime || "09:00",
endTime: appointment.endTime || "09:30",
type: appointment.type?.startsWith("other:") ? "other" : appointment.type,
notes: appointment.notes || "",
status: appointment.status || "scheduled",
staffId: typeof appointment.staffId === "number"
? appointment.staffId
: undefined,
}
: prefillData
? {
userId: user?.id,
patientId: prefillData.patientId,
date: prefillData.date ? parseLocalDate(prefillData.date) : new Date(),
title: "",
startTime: prefillData.startTime,
endTime: prefillData.endTime,
type: prefillData.type || "checkup",
status: "scheduled",
notes: "",
staffId: prefillData.staffId,
}
: {
userId: user?.id ?? 0,
date: new Date(),
title: "",
startTime: "09:00",
endTime: "09:30",
type: "checkup",
status: "scheduled",
staffId: staffMembers?.[0]?.id ?? undefined,
};
const form = useForm({
resolver: zodResolver(insertAppointmentSchema),
defaultValues,
});
// -----------------------------
// PATIENT SEARCH (simple inline search)
// -----------------------------
const [selectOpen, setSelectOpen] = useState(false);
const [patientSearchTerm, setPatientSearchTerm] = useState("");
const [debouncedPatientSearch] = useDebounce(patientSearchTerm, 300);
const searchKeyPart = debouncedPatientSearch.trim() || "recent";
const queryFn = async () => {
const trimmed = debouncedPatientSearch.trim();
const url = trimmed
? `/api/patients/search?name=${encodeURIComponent(trimmed)}&limit=50&offset=0`
: `/api/patients/recent?limit=50&offset=0`;
const res = await apiRequest("GET", url);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Failed to fetch patients" }));
throw new Error(err.message || "Failed to fetch patients");
}
const payload = await res.json();
return Array.isArray(payload) ? payload : (payload.patients ?? []);
};
const { data: patients = [], isFetching: isFetchingPatients, refetch: refetchPatients, } = useQuery({
queryKey: ["patients-dropdown", searchKeyPart],
queryFn,
enabled: selectOpen || debouncedPatientSearch.trim().length > 0,
});
useEffect(() => {
if (selectOpen && patients.length === 0) {
refetchPatients();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectOpen]);
// Prefill form from prefillData prop (new appointment slot click)
useEffect(() => {
if (!prefillData)
return;
form.setValue("staffId", prefillData.staffId);
form.setValue("startTime", prefillData.startTime);
form.setValue("endTime", prefillData.endTime);
form.setValue("date", parseLocalDate(prefillData.date));
if (prefillData.type)
form.setValue("type", prefillData.type);
if (prefillData.patientId) {
form.setValue("patientId", prefillData.patientId);
(async () => {
try {
const res = await apiRequest("GET", `/api/patients/${prefillData.patientId}`);
if (res.ok)
setPrefillPatient(await res.json());
}
catch { }
})();
}
}, [prefillData]);
// When editing an appointment, ensure we prefill the patient so SelectValue can render
useEffect(() => {
if (!appointment?.patientId)
return;
const pid = Number(appointment.patientId);
if (Number.isNaN(pid))
return;
// set form value immediately so the select has a value
form.setValue("patientId", pid);
// fetch the single patient record and set prefill
(async () => {
try {
const res = await apiRequest("GET", `/api/patients/${pid}`);
if (res.ok) {
const patientRecord = await res.json();
setPrefillPatient(patientRecord);
}
else {
let msg = `Failed to load patient (status ${res.status})`;
try {
const body = await res.json().catch(() => null);
if (body && body.message)
msg = body.message;
}
catch { }
toast({
title: "Could not load patient",
description: msg,
variant: "destructive",
});
}
}
catch (err) {
toast({
title: "Error fetching patient",
description: err?.message ||
"An unknown error occurred while fetching patient details.",
variant: "destructive",
});
}
})();
// note: we intentionally do NOT remove prefillPatientd here; it will be cleared when dropdown opens and main list contains the patient
}, [appointment?.patientId]);
const handleSubmit = (data) => {
// Make sure patientId is a number
const patientId = typeof data.patientId === "string"
? parseInt(data.patientId, 10)
: data.patientId;
// Auto-create title if it's empty
let title = data.title;
if (!title || title.trim() === "") {
// Format: "April 19" - just the date
title = format(data.date, "MMMM d");
}
const notes = data.notes || "";
const selectedStaff = staffMembers.find((staff) => staff.id?.toString() === data.staffId) ||
staffMembers[0];
if (!selectedStaff) {
console.error("No staff selected and no available staff in the list");
return;
}
const formattedDate = formatLocalDate(data.date);
const resolvedType = data.type === "other" && otherTypeDesc.trim()
? `other:${otherTypeDesc.trim()}`
: data.type;
onSubmit({
...data,
userId: Number(user?.id),
title,
notes,
patientId,
date: formattedDate,
startTime: data.startTime,
endTime: data.endTime,
type: resolvedType,
// Lock the type when the user has explicitly changed it on an existing appointment
...(appointment && typeChangedByUser ? { typeLocked: true } : {}),
});
};
return (<div className="form-container">
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => {
handleSubmit(data);
}, (errors) => {
console.error("Validation failed:", errors);
})} className="space-y-6">
<FormField control={form.control} name="patientId" render={({ field }) => (<FormItem>
<FormLabel>Patient</FormLabel>
<Select disabled={isLoading} onOpenChange={(open) => {
setSelectOpen(open);
if (!open) {
setPatientSearchTerm("");
if (prefillPatient &&
patients &&
patients.some((p) => Number(p.id) === Number(prefillPatient.id))) {
setPrefillPatient(null);
}
}
else {
if (!patients || patients.length === 0)
refetchPatients();
}
}} value={field.value == null || // null or undefined
(typeof field.value === "number" &&
!Number.isFinite(field.value)) || // NaN/Infinity
(typeof field.value === "string" &&
field.value.trim() === "") || // empty string
field.value === "NaN" // defensive check
? ""
: String(field.value)} onValueChange={(val) => field.onChange(val === "" ? undefined : Number(val))}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a patient"/>
</SelectTrigger>
</FormControl>
<SelectContent>
<div className="p-2" onKeyDown={(e) => e.stopPropagation()}>
<Input placeholder="Search by name..." value={patientSearchTerm} onChange={(e) => setPatientSearchTerm(e.target.value)} onClick={(e) => e.stopPropagation()}/>
</div>
{/* Prefill patient only if main list does not already include them */}
{prefillPatient &&
!patients.some((p) => Number(p.id) === Number(prefillPatient.id)) && (<SelectItem key={`prefill-${prefillPatient.id}`} value={prefillPatient.id?.toString() ?? ""}>
<div className="flex flex-col items-start">
<span className="font-medium">
{prefillPatient.firstName}{" "}
{prefillPatient.lastName}
</span>
<span className="text-xs text-muted-foreground">
DOB:{" "}
{prefillPatient.dateOfBirth
? new Date(prefillPatient.dateOfBirth).toLocaleDateString()
: ""}{" "}
{prefillPatient.phone ?? ""}
</span>
</div>
</SelectItem>)}
<div className="max-h-60 overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/30">
{isFetchingPatients ? (<div className="p-2 text-sm text-muted-foreground">
Loading...
</div>) : patients && patients.length > 0 ? (patients.map((patient) => (<SelectItem key={patient.id} value={patient.id?.toString() ?? ""}>
<div className="flex flex-col items-start">
<span className="font-medium">
{patient.firstName} {patient.lastName}
</span>
<span className="text-xs text-muted-foreground">
DOB:{" "}
{new Date(patient.dateOfBirth).toLocaleDateString()}{" "}
{patient.phone}
</span>
</div>
</SelectItem>))) : (<div className="p-2 text-muted-foreground text-sm">
No patients found
</div>)}
</div>
</SelectContent>
</Select>
<FormMessage />
</FormItem>)}/>
<FormField control={form.control} name="title" render={({ field }) => (<FormItem>
<FormLabel>
Appointment Title{" "}
<span className="text-muted-foreground text-xs">
(optional)
</span>
</FormLabel>
<FormControl>
<Input placeholder="Leave blank to auto-fill with date" {...field} disabled={isLoading}/>
</FormControl>
<FormMessage />
</FormItem>)}/>
<DateInputField control={form.control} name="date" label="Date"/>
<div className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="startTime" render={({ field }) => (<FormItem>
<FormLabel>Start Time</FormLabel>
<FormControl>
<div className="relative">
<Clock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground"/>
<Input placeholder="09:00" {...field} disabled={isLoading} className="pl-10" value={typeof field.value === "string" ? field.value : ""}/>
</div>
</FormControl>
<FormMessage />
</FormItem>)}/>
<FormField control={form.control} name="endTime" render={({ field }) => (<FormItem>
<FormLabel>End Time</FormLabel>
<FormControl>
<div className="relative">
<Clock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground"/>
<Input placeholder="09:30" {...field} disabled={isLoading} className="pl-10" value={typeof field.value === "string" ? field.value : ""}/>
</div>
</FormControl>
<FormMessage />
</FormItem>)}/>
</div>
<FormField control={form.control} name="type" render={({ field }) => (<FormItem>
<FormLabel>Appointment Type</FormLabel>
<Select disabled={isLoading} onValueChange={(val) => {
field.onChange(val);
if (val !== "other")
setOtherTypeDesc("");
if (val !== originalType.current)
setTypeChangedByUser(true);
}} value={field.value} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a type"/>
</SelectTrigger>
</FormControl>
<SelectContent>
{APPOINTMENT_TYPES.map((t) => (<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>))}
</SelectContent>
</Select>
{field.value === "other" && (<Input className="mt-2" placeholder="Describe the appointment type…" value={otherTypeDesc} onChange={(e) => setOtherTypeDesc(e.target.value)} disabled={isLoading} autoFocus/>)}
<FormMessage />
</FormItem>)}/>
<FormField control={form.control} name="status" render={({ field }) => (<FormItem>
<FormLabel>Status</FormLabel>
<Select disabled={isLoading} onValueChange={field.onChange} value={field.value} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status"/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="scheduled">Scheduled</SelectItem>
<SelectItem value="confirmed">Confirmed</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
<SelectItem value="no-show">No Show</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>)}/>
<FormField control={form.control} name="staffId" render={({ field }) => (<FormItem>
<FormLabel>Doctor/Hygienist</FormLabel>
<Select disabled={isLoading} onValueChange={(val) => field.onChange(Number(val))} value={field.value ? String(field.value) : undefined} defaultValue={field.value ? String(field.value) : undefined}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select staff member"/>
</SelectTrigger>
</FormControl>
<SelectContent>
{staffMembers.map((staff) => (<SelectItem key={staff.id} value={staff.id?.toString() || ""}>
{staff.name} ({staff.role})
</SelectItem>))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>)}/>
<FormField control={form.control} name="notes" render={({ field }) => (<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea placeholder="Enter any notes about the appointment" {...field} disabled={isLoading} className="min-h-24" value={field.value ?? ""}/>
</FormControl>
<FormMessage />
</FormItem>)}/>
<Button type="submit" disabled={isLoading} className="w-full">
{appointment ? "Update Appointment" : "Create Appointment"}
</Button>
{appointment?.id && onDelete && (<Button type="button" onClick={() => {
onOpenChange?.(false); // 👈 Close the modal first
setTimeout(() => {
onDelete?.(appointment.id);
}, 300); // 300ms is safe for most animations
}} className="bg-red-600 text-white w-full rounded hover:bg-red-700">
Delete Appointment
</Button>)}
</form>
</Form>
</div>);
}

View File

@@ -0,0 +1,34 @@
import * as React from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip";
export function PatientStatusBadge({ status, className = "", size = 10, }) {
const { bg, label } = getVisuals(status);
return (<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span aria-label={`Patient status: ${label}`} className={`inline-block rounded-full shadow ${className}`} style={{
width: size,
height: size,
backgroundColor: bg,
position: "absolute",
top: "-6px", // stick out above card
right: "-6px", // stick out right
}}/>
</TooltipTrigger>
<TooltipContent side="top" className="px-2 py-1 text-xs">
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>);
}
function getVisuals(status) {
switch (status) {
case "ACTIVE":
return { label: "Active", bg: "#16A34A" }; // MEDICAL GREEN (not same as staff green)
case "INACTIVE":
return { label: "Inactive", bg: "#DC2626" };
case "PLAN_NOT_ACCEPTED":
return { label: "Plan Not Accepted", bg: "#F59E0B" }; // amber
default:
return { label: "Unknown", bg: "#6B7280" };
}
}

View File

@@ -0,0 +1,258 @@
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";
const STATUS_COLORS = {
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 = {
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 = () => ({
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([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState(newOrder());
const openAdd = () => {
setEditing(newOrder());
setDialogOpen(true);
};
const openEdit = (order) => {
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) => {
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 }))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(STATUS_LABELS).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,224 @@
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";
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 = () => ({
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([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState(newRx());
const openAdd = () => {
setEditing(newRx());
setDialogOpen(true);
};
const openEdit = (rx) => {
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) => {
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,155 @@
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";
const CONDITIONS = [
{ 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 = {
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 = () => {
const s = {};
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) {
return CONDITIONS.find((c) => c.value === condition) ?? { value: "healthy", label: "Healthy", color: "border-gray-300", bg: "bg-white" };
}
function ToothBox({ number, state, selected, isUpper, onClick }) {
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(null);
const [draft, setDraft] = useState({ condition: "healthy", notes: "" });
const handleSelect = (n) => {
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 }))}>
<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,202 @@
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";
const STATUS_COLORS = {
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 = {
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 = () => ({
id: nextId++,
tooth: "",
procedure: "",
fee: 0,
status: "planned",
date: new Date().toISOString().substring(0, 10),
provider: "",
notes: "",
});
export function TreatmentPlanTab() {
const [entries, setEntries] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState(newEntry());
const openAdd = () => {
setEditing(newEntry());
setDialogOpen(true);
};
const openEdit = (entry) => {
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) => {
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 }))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(STATUS_LABELS).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

@@ -0,0 +1,104 @@
import React, { useCallback, useRef, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, FilePlus } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { MultipleFileUploadZone, } from "../file-upload/multiple-file-upload-zone";
export default function ClaimDocumentsUploadMultiple() {
const { toast } = useToast();
// Internal configuration
const MAX_FILES = 10;
const ACCEPTED_FILE_TYPES = "application/pdf,image/jpeg,image/jpg,image/png,image/webp";
const TITLE = "Upload Claim Document(s)";
const DESCRIPTION = "You can upload up to 10 files. Allowed types: PDF, JPG, PNG, WEBP.";
// Zone ref + minimal UI state (parent does not own files)
const uploadZoneRef = useRef(null);
const [filesForUI, setFilesForUI] = useState([]);
const [isUploading, setIsUploading] = useState(false); // forwarded to upload zone
const [isExtracting, setIsExtracting] = useState(false);
// Called by MultipleFileUploadZone when its internal list changes (UI-only)
const handleZoneFilesChange = useCallback((files) => {
setFilesForUI(files);
}, []);
// Dummy save (simulate async). Replace with real API call when needed.
const handleSave = useCallback(async (files) => {
// simulate network / processing time
await new Promise((res) => setTimeout(res, 800));
console.log("handleSave called for files:", files.map((f) => f.name));
}, []);
// Extract handler — reads files from the zone via ref and calls handleSave
const handleExtract = useCallback(async () => {
const files = uploadZoneRef.current?.getFiles() ?? [];
if (files.length === 0) {
toast({
title: "No files",
description: "Please upload at least one file before extracting.",
variant: "destructive",
});
return;
}
if (isExtracting)
return;
setIsExtracting(true);
try {
await handleSave(files);
toast({
title: "Extraction started",
description: `Processing ${files.length} file(s).`,
variant: "default",
});
// we intentionally leave files intact in the zone after extraction
}
catch (err) {
toast({
title: "Extraction failed",
description: "There was an error starting extraction. Please try again.",
variant: "destructive",
});
// eslint-disable-next-line no-console
console.error("extract error", err);
}
finally {
setIsExtracting(false);
}
}, [handleSave, isExtracting, toast]);
return (<div className="space-y-8 py-8">
<Card>
<CardHeader>
<CardTitle>{TITLE}</CardTitle>
<CardDescription>{DESCRIPTION}</CardDescription>
</CardHeader>
<CardContent>
{/* File Upload Section */}
<div className="bg-gray-100 p-4 rounded-md space-y-4">
<MultipleFileUploadZone ref={uploadZoneRef} onFilesChange={handleZoneFilesChange} isUploading={isUploading} acceptedFileTypes={ACCEPTED_FILE_TYPES} maxFiles={MAX_FILES}/>
{/* Show list of files received from the upload zone */}
{filesForUI.length > 0 && (<div>
<p className="text-sm text-gray-600 mb-2">
Uploaded ({filesForUI.length}/{MAX_FILES})
</p>
<ul className="text-sm text-gray-700 list-disc ml-6 max-h-40 overflow-auto">
{filesForUI.map((file, index) => (<li key={index} className="truncate" title={file.name}>
{file.name}
</li>))}
</ul>
</div>)}
</div>
{/* Action Button */}
<div className="mt-4">
<Button className="w-full h-12 gap-2" disabled={filesForUI.length === 0 || isExtracting} onClick={handleExtract}>
{isExtracting ? (<>
<RefreshCw className="h-4 w-4 animate-spin"/>
Processing...
</>) : (<>
<FilePlus className="h-4 w-4"/>
Extract Claim Data
</>)}
</Button>
</div>
</CardContent>
</Card>
</div>);
}

View File

@@ -0,0 +1,271 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import React, { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { safeParseMissingTeeth, splitTeeth, ToothChip, toStatusLabel, } from "./tooth-ui";
export default function ClaimEditModal({ isOpen, onOpenChange, onClose, claim, onSave, }) {
const [status, setStatus] = useState(claim?.status ?? "PENDING");
const [claimNumber, setClaimNumber] = useState(claim?.claimNumber ?? "");
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState(claim?.npiProviderId ?? null);
const { data: npiProviders = [] } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
return res.json();
},
});
// Default to Mary Scannell (or first provider) only when no provider is set
useEffect(() => {
if (!npiProviders.length)
return;
if (selectedNpiProviderId !== null)
return;
const mary = npiProviders.find((p) => p.providerName.toLowerCase().includes("mary scannell"));
const fallback = mary ?? npiProviders[0];
if (fallback)
setSelectedNpiProviderId(fallback.id);
}, [npiProviders]);
if (!claim)
return null;
const handleSave = () => {
const updatedClaim = {
...claim,
status,
claimNumber: claimNumber.trim() || null,
npiProviderId: selectedNpiProviderId,
npiProvider: npiProviders.find((p) => p.id === selectedNpiProviderId) ?? null,
};
onSave(updatedClaim);
onOpenChange(false);
};
return (<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Claim Status</DialogTitle>
<DialogDescription>Update the status of the claim.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Patient Details */}
<div className="flex items-center space-x-4">
<div className="h-16 w-16 rounded-full bg-blue-600 text-white flex items-center justify-center text-xl font-medium">
{claim.patientName.charAt(0)}
</div>
<div>
<h3 className="text-xl font-semibold">{claim.patientName}</h3>
<p className="text-gray-500">
Claim ID: {claim.id?.toString().padStart(4, "0")}
</p>
<p className="text-gray-500">
Claim No: {claimNumber || "—"}
</p>
</div>
</div>
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<h4 className="font-medium text-gray-900">Basic Information</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Date of Birth:</span>{" "}
{formatDateToHumanReadable(claim.dateOfBirth)}
</p>
<p>
<span className="text-gray-500">Service Date:</span>{" "}
{formatDateToHumanReadable(claim.serviceDate)}
</p>
<div>
<span className="text-gray-500">Status:</span>
<Select value={status} onValueChange={(value) => setStatus(value)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue placeholder="Select status"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="REVIEW">Review</SelectItem>
<SelectItem value="APPROVED">Approved</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<span className="text-gray-500">Rendering Provider:</span>
<Select value={selectedNpiProviderId?.toString() ?? ""} onValueChange={(val) => setSelectedNpiProviderId(Number(val))}>
<SelectTrigger className="mt-1 w-full">
<SelectValue placeholder="Select Provider"/>
</SelectTrigger>
<SelectContent>
{npiProviders.map((p) => (<SelectItem key={p.id} value={p.id.toString()}>
{p.npiNumber} {p.providerName}
</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900">Insurance Details</h4>
<div className="mt-2 space-y-2">
<div>
<span className="text-gray-500">Claim Number:</span>
<Input className="mt-1" value={claimNumber} onChange={(e) => setClaimNumber(e.target.value)} placeholder="Enter claim number"/>
</div>
<p>
<span className="text-gray-500">Insurance Provider:</span>{" "}
{claim.insuranceProvider || "N/A"}
</p>
<p>
<span className="text-gray-500">Member ID:</span>{" "}
{claim.memberId}
</p>
<p>
<span className="text-gray-500">Remarks:</span>{" "}
{claim.remarks || "N/A"}
</p>
</div>
</div>
</div>
{/* Timestamps */}
<div className="space-y-2">
<h4 className="font-medium text-gray-900">Timestamps</h4>
<p>
<span className="text-gray-500">Created At:</span>{" "}
{formatDateToHumanReadable(claim.createdAt)}
</p>
<p>
<span className="text-gray-500">Updated At:</span>{" "}
{formatDateToHumanReadable(claim.updatedAt)}
</p>
</div>
{/* Staff Info */}
{claim.staff && (<div className="space-y-2 pt-4">
<h4 className="font-medium text-gray-900">Assigned Staff</h4>
<p>
<span className="text-gray-500">Name:</span> {claim.staff.name}
</p>
<p>
<span className="text-gray-500">Role:</span> {claim.staff.role}
</p>
{claim.staff.email && (<p>
<span className="text-gray-500">Email:</span>{" "}
{claim.staff.email}
</p>)}
{claim.staff.phone && (<p>
<span className="text-gray-500">Phone:</span>{" "}
{claim.staff.phone}
</p>)}
</div>)}
{/* Service Lines */}
<div>
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
<div className="mt-2 space-y-3">
{claim.serviceLines.length > 0 ? (<>
{claim.serviceLines.map((line) => (<div key={line.id} className="border p-3 rounded-md bg-gray-50">
<p>
<span className="text-gray-500">Procedure Code:</span>{" "}
{line.procedureCode}
</p>
<p>
<span className="text-gray-500">Procedure Date:</span>{" "}
{formatDateToHumanReadable(line.procedureDate)}
</p>
{line.quad && (<p>
<span className="text-gray-500">Quad:</span>{" "}
{line.quad}
</p>)}
{line.arch && (<p>
<span className="text-gray-500">Arch:</span>{" "}
{line.arch}
</p>)}
{line.toothNumber && (<p>
<span className="text-gray-500">Tooth Number:</span>{" "}
{line.toothNumber}
</p>)}
{line.toothSurface && (<p>
<span className="text-gray-500">Tooth Surface:</span>{" "}
{line.toothSurface}
</p>)}
<p>
<span className="text-gray-500">Billed Amount:</span> $
{Number(line.totalBilled).toFixed(2)}
</p>
</div>))}
<div className="text-right font-semibold text-gray-900 pt-2 border-t mt-4">
Total Billed Amount: $
{claim.serviceLines
.reduce((total, line) => {
const billed = line.totalBilled
? parseFloat(line.totalBilled)
: 0;
return total + billed;
}, 0)
.toFixed(2)}
</div>
</>) : (<p className="text-gray-500">No service lines available.</p>)}
</div>
</div>
{/* Missing Teeth */}
<div className="space-y-2 pt-4">
<h4 className="font-medium text-gray-900">Missing Teeth</h4>
<p>
<span className="text-gray-500">Status:</span>{" "}
{toStatusLabel(claim.missingTeethStatus)}
</p>
{/* Only show details when the user chose "Specify Missing" */}
{claim.missingTeethStatus === "Yes_missing" &&
(() => {
const map = safeParseMissingTeeth(claim.missingTeeth);
const { permanent, primary } = splitTeeth(map);
const hasAny = permanent.length > 0 || primary.length > 0;
if (!hasAny) {
return (<p className="text-gray-500">
No specific teeth marked as missing.
</p>);
}
return (<div className="mt-2 space-y-3">
{permanent.length > 0 && (<div>
<div className="text-xs font-medium text-gray-600 mb-2">
Permanent
</div>
<div className="flex flex-wrap gap-2">
{permanent.map((t) => (<ToothChip key={t.name} name={t.name} v={t.v}/>))}
</div>
</div>)}
{primary.length > 0 && (<div>
<div className="text-xs font-medium text-gray-600 mb-2">
Primary
</div>
<div className="flex flex-wrap gap-2">
{primary.map((t) => (<ToothChip key={t.name} name={t.name} v={t.v}/>))}
</div>
</div>)}
</div>);
})()}
{claim.missingTeethStatus === "endentulous" && (<p className="text-sm text-gray-700">Patient is edentulous.</p>)}
</div>
{/* Actions */}
<div className="flex justify-end space-x-2 pt-4">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</div>
</div>
</DialogContent>
</Dialog>);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,272 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import React from "react";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { FileText, Paperclip } from "lucide-react";
import { safeParseMissingTeeth, splitTeeth, ToothChip, toStatusLabel, } from "./tooth-ui";
export default function ClaimViewModal({ isOpen, onOpenChange, onClose, claim, onEditClaim, }) {
// Normalizer: supports both ClaimFile[] and nested-create shape { create: ClaimFile[] }
const getClaimFilesArray = (c) => {
if (!c)
return [];
// If it's already a plain array (runtime from Prisma include), return it
const maybeFiles = c.claimFiles;
if (!maybeFiles)
return [];
if (Array.isArray(maybeFiles)) {
// ensure each item has filename field (best-effort)
return maybeFiles.map((f) => ({
id: f?.id,
filename: String(f?.filename ?? ""),
mimeType: f?.mimeType ?? f?.mime ?? null,
}));
}
// Nested-create shape: { create: [...] }
if (maybeFiles && Array.isArray(maybeFiles.create)) {
return maybeFiles.create.map((f) => ({
id: f?.id,
filename: String(f?.filename ?? ""),
mimeType: f?.mimeType ?? f?.mime ?? null,
}));
}
// No recognized shape -> empty
return [];
};
const claimFiles = getClaimFilesArray(claim);
return (<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Claim Details</DialogTitle>
<DialogDescription>
Detailed view of the selected claim.
</DialogDescription>
</DialogHeader>
{claim && (<div className="space-y-4">
<div className="flex items-center space-x-4">
<div className="h-16 w-16 rounded-full bg-blue-600 text-white flex items-center justify-center text-xl font-medium">
{claim.patientName.charAt(0)}
</div>
<div>
<h3 className="text-xl font-semibold">{claim.patientName}</h3>
<p className="text-gray-500">
Claim ID: {claim.id?.toString().padStart(4, "0")}
</p>
<p className="text-gray-500">
Claim No: {claim.claimNumber || "—"}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<h4 className="font-medium text-gray-900">Basic Information</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Date of Birth:</span>{" "}
{formatDateToHumanReadable(claim.dateOfBirth)}
</p>
<p>
<span className="text-gray-500">Service Date:</span>{" "}
{formatDateToHumanReadable(claim.serviceDate)}
</p>
<p>
<span className="text-gray-500">Status:</span>{" "}
<span className={`font-medium ${claim.status === "APPROVED"
? "text-green-600"
: claim.status === "CANCELLED"
? "text-red-600"
: claim.status === "REVIEW"
? "text-yellow-600"
: "text-gray-600"}`}>
{claim?.status
? claim.status.charAt(0).toUpperCase() +
claim.status.slice(1).toLowerCase()
: "Unknown"}
</span>
</p>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900">Insurance Details</h4>
<div className="mt-2 space-y-2">
<p>
<span className="text-gray-500">Claim Number:</span>{" "}
{claim.claimNumber || "—"}
</p>
<p>
<span className="text-gray-500">Insurance Provider:</span>{" "}
{claim.insuranceProvider || "N/A"}
</p>
<p>
<span className="text-gray-500">Member ID:</span>{" "}
{claim.memberId}
</p>
<p>
<span className="text-gray-500">Remarks:</span>{" "}
{claim.remarks || "N/A"}
</p>
</div>
</div>
</div>
{/* Metadata */}
<div className="space-y-2">
<h4 className="font-medium text-gray-900">Timestamps</h4>
<p>
<span className="text-gray-500">Created At:</span>{" "}
{formatDateToHumanReadable(claim.createdAt)}
</p>
<p>
<span className="text-gray-500">Updated At:</span>{" "}
{formatDateToHumanReadable(claim.updatedAt)}
</p>
</div>
{claim.staff && (<div className="space-y-2 pt-4">
<h4 className="font-medium text-gray-900">Assigned Staff</h4>
<p>
<span className="text-gray-500">Name:</span>{" "}
{claim.staff.name}
</p>
<p>
<span className="text-gray-500">Role:</span>{" "}
{claim.staff.role}
</p>
{claim.staff.email && (<p>
<span className="text-gray-500">Email:</span>{" "}
{claim.staff.email}
</p>)}
{claim.staff.phone && (<p>
<span className="text-gray-500">Phone:</span>{" "}
{claim.staff.phone}
</p>)}
</div>)}
<div>
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
<div className="mt-2 space-y-3">
{claim.serviceLines.length > 0 ? (<>
{claim.serviceLines.map((line, index) => (<div key={line.id} className="border p-3 rounded-md bg-gray-50">
<p>
<span className="text-gray-500">Procedure Code:</span>{" "}
{line.procedureCode}
</p>
<p>
<span className="text-gray-500">Procedure Date:</span>{" "}
{formatDateToHumanReadable(line.procedureDate)}
</p>
{line.quad && (<p>
<span className="text-gray-500">Quad:</span>{" "}
{line.quad}
</p>)}
{line.arch && (<p>
<span className="text-gray-500">Arch:</span>{" "}
{line.arch}
</p>)}
{line.toothNumber && (<p>
<span className="text-gray-500">Tooth Number:</span>{" "}
{line.toothNumber}
</p>)}
{line.toothSurface && (<p>
<span className="text-gray-500">
Tooth Surface:
</span>{" "}
{line.toothSurface}
</p>)}
<p>
<span className="text-gray-500">Billed Amount:</span>{" "}
${Number(line.totalBilled).toFixed(2)}
</p>
</div>))}
<div className="text-right font-semibold text-gray-900 pt-2 border-t mt-4">
Total Billed Amount: $
{claim.serviceLines
.reduce((total, line) => total + Number(line.totalBilled || 0), 0)
.toFixed(2)}
</div>
</>) : (<p className="text-gray-500">No service lines available.</p>)}
</div>
</div>
{/* Missing Teeth */}
<div className="space-y-2 pt-4">
<h4 className="font-medium text-gray-900">Missing Teeth</h4>
<p>
<span className="text-gray-500">Status:</span>{" "}
{toStatusLabel(claim.missingTeethStatus)}
</p>
{/* Only show details when the user chose "Specify Missing" */}
{claim.missingTeethStatus === "Yes_missing" &&
(() => {
const map = safeParseMissingTeeth(claim.missingTeeth);
const { permanent, primary } = splitTeeth(map);
const hasAny = permanent.length > 0 || primary.length > 0;
if (!hasAny) {
return (<p className="text-gray-500">
No specific teeth marked as missing.
</p>);
}
return (<div className="mt-2 space-y-3">
{permanent.length > 0 && (<div>
<div className="text-xs font-medium text-gray-600 mb-2">
Permanent
</div>
<div className="flex flex-wrap gap-2">
{permanent.map((t) => (<ToothChip key={t.name} name={t.name} v={t.v}/>))}
</div>
</div>)}
{primary.length > 0 && (<div>
<div className="text-xs font-medium text-gray-600 mb-2">
Primary
</div>
<div className="flex flex-wrap gap-2">
{primary.map((t) => (<ToothChip key={t.name} name={t.name} v={t.v}/>))}
</div>
</div>)}
</div>);
})()}
{claim.missingTeethStatus === "endentulous" && (<p className="text-sm text-gray-700">Patient is edentulous.</p>)}
</div>
{/* Claim Files (metadata) */}
<div className="pt-4">
<h4 className="font-medium text-gray-900 flex items-center space-x-2">
<Paperclip className="w-4 h-4 inline-block"/>
<span>Attached Files</span>
</h4>
{claimFiles.length > 0 ? (<ul className="mt-3 space-y-2">
{claimFiles.map((f) => (<li key={f.id ?? f.filename} className="flex items-center justify-between border rounded-md p-3 bg-white">
<div className="flex items-start space-x-3">
<FileText className="w-5 h-5 text-gray-500 mt-1"/>
<div>
<div className="font-medium">{f.filename}</div>
<div className="text-xs text-gray-500">
{f.mimeType || "unknown"}
</div>
</div>
</div>
</li>))}
</ul>) : (<p className="mt-2 text-gray-500">No files attached.</p>)}
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => {
onOpenChange(false);
onEditClaim(claim);
}}>
Edit Claim
</Button>
</div>
</div>)}
</DialogContent>
</Dialog>);
}

View File

@@ -0,0 +1,52 @@
import { useState } from "react";
import ClaimsRecentTable from "./claims-recent-table";
import { PatientTable } from "../patients/patient-table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../ui/card";
export default function ClaimsOfPatientModal({ onNewClaim, }) {
const [selectedPatient, setSelectedPatient] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [claimsPage, setClaimsPage] = useState(1);
const handleSelectPatient = (patient) => {
if (patient) {
setSelectedPatient(patient);
setClaimsPage(1);
setIsModalOpen(true);
}
else {
setSelectedPatient(null);
setIsModalOpen(false);
}
};
return (<div className="space-y-8 py-8">
{/* Claims Section */}
{selectedPatient && (<Card>
<CardHeader>
<CardTitle>
Claims for {selectedPatient.firstName} {selectedPatient.lastName}
</CardTitle>
<CardDescription>
Displaying recent claims for the selected patient.
</CardDescription>
</CardHeader>
<CardContent>
<ClaimsRecentTable patientId={selectedPatient.id} allowView allowEdit allowDelete onPageChange={setClaimsPage}/>
</CardContent>
</Card>)}
{/* Patients Section */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select any patient and View all their recent claims.
</CardDescription>
<CardDescription>
Also create new claim for any patients.
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable allowView allowCheckbox allowNewClaim onNewClaim={onNewClaim} onSelectPatient={handleSelectPatient}/>
</CardContent>
</Card>
</div>);
}

View File

@@ -0,0 +1,481 @@
import { useEffect, useState, useMemo } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { AlertCircle, CheckCircle, Clock, Delete, Edit, Eye, Paperclip, } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { useAppDispatch } from "@/redux/hooks";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import LoadingScreen from "@/components/ui/LoadingScreen";
import { Checkbox } from "@/components/ui/checkbox";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import ClaimViewModal from "./claim-view-modal";
import ClaimEditModal from "./claim-edit-modal";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
// 🔑 exported base key
export const QK_CLAIMS_BASE = ["claims-recent"];
// helper for specific pages/patient scope
export const qkClaimsRecent = (opts) => opts.patientId
? [...QK_CLAIMS_BASE, "patient", opts.patientId, opts.page]
: [...QK_CLAIMS_BASE, "global", opts.page];
export default function ClaimsRecentTable({ allowEdit, allowView, allowDelete, allowCheckbox, onSelectClaim, onPageChange, patientId, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const [isViewClaimOpen, setIsViewClaimOpen] = useState(false);
const [isEditClaimOpen, setIsEditClaimOpen] = useState(false);
const [isDeleteClaimOpen, setIsDeleteClaimOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const claimsPerPage = 5;
const offset = (currentPage - 1) * claimsPerPage;
const [currentClaim, setCurrentClaim] = useState(undefined);
const [selectedClaimId, setSelectedClaimId] = useState(null);
const handleSelectClaim = (claim) => {
const isSelected = selectedClaimId === claim.id;
const newSelectedId = isSelected ? null : claim.id;
setSelectedClaimId(Number(newSelectedId));
if (onSelectClaim) {
onSelectClaim(isSelected ? null : claim);
}
};
useEffect(() => {
setCurrentPage(1);
}, [patientId]);
const queryKey = qkClaimsRecent({
patientId: patientId ?? undefined,
page: currentPage,
});
const { data: claimsData, isLoading, isError, } = useQuery({
queryKey,
queryFn: async () => {
const endpoint = patientId
? `/api/claims/patient/${patientId}?limit=${claimsPerPage}&offset=${offset}`
: `/api/claims/recent?limit=${claimsPerPage}&offset=${offset}`;
const res = await apiRequest("GET", endpoint);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || "Search failed");
}
return res.json();
},
placeholderData: { claims: [], totalCount: 0 },
});
const updateClaimMutation = useMutation({
mutationFn: async (claim) => {
const response = await apiRequest("PUT", `/api/claims/${claim.id}`, {
status: claim.status,
...(claim.claimNumber != null
? { claimNumber: claim.claimNumber }
: {}),
...(claim.npiProviderId != null
? { npiProviderId: claim.npiProviderId }
: {}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update claim");
}
return response.json();
},
onSuccess: () => {
setIsEditClaimOpen(false);
toast({
title: "Success",
description: "Claim updated successfully!",
variant: "default",
});
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
},
onError: (error) => {
toast({
title: "Error",
description: `Update failed: ${error.message}`,
variant: "destructive",
});
},
});
const deleteClaimMutation = useMutation({
mutationFn: async (id) => {
await apiRequest("DELETE", `/api/claims/${id}`);
return;
},
onSuccess: () => {
setIsDeleteClaimOpen(false);
queryClient.invalidateQueries({ queryKey: QK_CLAIMS_BASE });
toast({
title: "Success",
description: "Claim deleted successfully!",
variant: "default",
});
},
onError: (error) => {
console.log(error);
toast({
title: "Error",
description: `Failed to delete claim: ${error.message}`,
variant: "destructive",
});
},
});
const handleEditClaim = (claim) => {
setCurrentClaim(claim);
setIsEditClaimOpen(true);
};
const handleViewClaim = (claim) => {
setCurrentClaim(claim);
setIsViewClaimOpen(true);
};
const handleDeleteClaim = (claim) => {
setCurrentClaim(claim);
setIsDeleteClaimOpen(true);
};
const handleConfirmDeleteClaim = async () => {
if (currentClaim) {
if (typeof currentClaim.id === "number") {
deleteClaimMutation.mutate(currentClaim.id);
}
else {
toast({
title: "Error",
description: "Selected claim is missing an ID for deletion.",
variant: "destructive",
});
}
}
else {
toast({
title: "Error",
description: "No patient selected for deletion.",
variant: "destructive",
});
}
};
useEffect(() => {
if (onPageChange)
onPageChange(currentPage);
}, [currentPage, onPageChange]);
const totalPages = useMemo(() => Math.ceil((claimsData?.totalCount || 0) / claimsPerPage), [claimsData?.totalCount, claimsPerPage]);
const startItem = offset + 1;
const endItem = Math.min(offset + claimsPerPage, claimsData?.totalCount || 0);
const getInitialsFromName = (fullName) => {
const parts = fullName.trim().split(/\s+/);
const filteredParts = parts.filter((part) => part.length > 0);
if (filteredParts.length === 0) {
return "";
}
const firstInitial = filteredParts[0].charAt(0).toUpperCase();
if (filteredParts.length === 1) {
return firstInitial;
}
else {
const lastInitial = filteredParts[filteredParts.length - 1].charAt(0).toUpperCase();
return firstInitial + lastInitial;
}
};
const getAvatarColor = (id) => {
const colorClasses = [
"bg-blue-500",
"bg-teal-500",
"bg-amber-500",
"bg-rose-500",
"bg-indigo-500",
"bg-green-500",
"bg-purple-500",
];
return colorClasses[id % colorClasses.length];
};
const getStatusInfo = (status) => {
switch (status) {
case "PENDING":
return {
label: "Pending",
color: "bg-yellow-100 text-yellow-800",
icon: <Clock className="h-3 w-3 mr-1"/>,
};
case "APPROVED":
return {
label: "Approved",
color: "bg-green-100 text-green-800",
icon: <CheckCircle className="h-3 w-3 mr-1"/>,
};
case "CANCELLED":
return {
label: "Cancelled",
color: "bg-red-100 text-red-800",
icon: <AlertCircle className="h-3 w-3 mr-1"/>,
};
default:
return {
label: status
? status.charAt(0).toUpperCase() + status.slice(1)
: "Unknown",
color: "bg-gray-100 text-gray-800",
icon: <AlertCircle className="h-3 w-3 mr-1"/>,
};
}
};
const getTotalBilled = (claim) => {
return claim.serviceLines.reduce((sum, line) => sum + Number(line.totalBilled || 0), 0);
};
return (<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{allowCheckbox && <TableHead>Select</TableHead>}
<TableHead>Claim ID</TableHead>
<TableHead>Claim No</TableHead>
<TableHead>PreAuth No</TableHead>
<TableHead>Patient Name</TableHead>
<TableHead>Service Date</TableHead>
<TableHead>Submission Date</TableHead>
<TableHead>Insurance Provider</TableHead>
<TableHead>Member ID</TableHead>
<TableHead>Total Billed</TableHead>
<TableHead>Status</TableHead>
<TableHead>Attachments</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<LoadingScreen />
</TableCell>
</TableRow>) : isError ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-red-500">
Error loading claims.
</TableCell>
</TableRow>) : (claimsData?.claims ?? []).length === 0 ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
No claims found.
</TableCell>
</TableRow>) : (claimsData?.claims.map((claim) => (<TableRow key={claim.id} className="hover:bg-gray-50">
{allowCheckbox && (<TableCell>
<Checkbox checked={selectedClaimId === claim.id} onCheckedChange={() => handleSelectClaim(claim)}/>
</TableCell>)}
<TableCell>
<div className="text-sm font-medium text-gray-900">
CLM-{claim.id.toString().padStart(4, "0")}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium text-gray-900">
{claim.claimNumber ?? "—"}
</div>
</TableCell>
<TableCell>
<div className="text-sm font-medium text-blue-700">
{claim.preAuthNumber ?? "—"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
<Avatar className={`h-10 w-10 ${getAvatarColor(claim.patientId)}`}>
<AvatarFallback className="text-white">
{getInitialsFromName(claim.patientName)}
</AvatarFallback>
</Avatar>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{claim.patientName}
</div>
<div className="text-sm text-gray-500">
DOB: {formatDateToHumanReadable(claim.dateOfBirth)}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.serviceDate ? formatDateToHumanReadable(claim.serviceDate) : "—"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{formatDateToHumanReadable(claim.createdAt)}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.insuranceProvider ?? "Not specified"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.memberId ?? "Not specified"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
${getTotalBilled(claim).toFixed(2)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{(() => {
const { label, color, icon } = getStatusInfo(claim.status);
return (<span className={`px-2 py-1 text-xs font-medium rounded-full ${color}`}>
<span className="flex items-center">
{icon}
{label}
</span>
</span>);
})()}
</div>
</TableCell>
<TableCell>
{claim.claimFiles && claim.claimFiles.length > 0 ? (<ul className="space-y-1">
{claim.claimFiles.map((f) => (<li key={f.id ?? f.filename} className="flex items-center gap-1 text-xs text-gray-700">
<Paperclip className="h-3 w-3 text-gray-400 shrink-0"/>
<span className="truncate max-w-[140px]" title={f.filename}>
{f.filename}
</span>
</li>))}
</ul>) : (<span className="text-xs text-gray-400"></span>)}
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{claim.npiProvider?.providerName ?? "—"}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{allowDelete && (<Button onClick={() => {
handleDeleteClaim(claim);
}} className="text-red-600 hover:text-red-900" aria-label="Delete Staff" variant="ghost" size="icon">
<Delete />
</Button>)}
{allowEdit && (<Button variant="ghost" size="icon" onClick={() => {
handleEditClaim(claim);
}} className="text-blue-600 hover:text-blue-800 hover:bg-blue-50">
<Edit className="h-4 w-4"/>
</Button>)}
{allowView && (<Button variant="ghost" size="icon" onClick={() => {
handleViewClaim(claim);
}} className="text-gray-600 hover:text-gray-800 hover:bg-gray-50">
<Eye className="h-4 w-4"/>
</Button>)}
{/* {allowView && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
dispatch(
setTaskStatus({
key: "claimSubmit",
status: "pending",
message: "Sending Data to Selenium...",
})
);
const response = await apiRequest(
"POST",
"/api/claims/mh-provider-login",
{
memberId: claim.memberId,
dateOfBirth: claim.dateOfBirth,
submissionDate: claim.createdAt,
firstName: claim.patientName?.split(' ')[0] || '',
lastName: claim.patientName?.split(' ').slice(1).join(' ') || '',
procedureCode: claim.serviceLines?.[0]?.procedureCode || '',
toothNumber: claim.serviceLines?.[0]?.toothNumber || '',
toothSurface: claim.serviceLines?.[0]?.toothSurface || '',
insuranceSiteKey: "MH",
}
);
const data = await response.json();
if (data?.error) throw new Error(data.error);
if (data?.status === "success") {
dispatch(
setTaskStatus({
key: "claimSubmit",
status: "success",
message: "Claims automation completed. Browser remains open.",
})
);
} else {
handleViewClaim(claim);
}
} catch {
dispatch(
setTaskStatus({
key: "claimSubmit",
status: "error",
message: "Selenium submission failed",
})
);
handleViewClaim(claim);
}
}}
className="text-blue-600 hover:text-blue-800 hover:bg-blue-50"
>
Claims
</Button>
)} */}
</div>
</TableCell>
</TableRow>)))}
</TableBody>
</Table>
</div>
<DeleteConfirmationDialog isOpen={isDeleteClaimOpen} onConfirm={handleConfirmDeleteClaim} onCancel={() => setIsDeleteClaimOpen(false)} entityName={currentClaim?.patientName}/>
{isViewClaimOpen && currentClaim && (<ClaimViewModal isOpen={isViewClaimOpen} onClose={() => setIsViewClaimOpen(false)} onOpenChange={(open) => setIsViewClaimOpen(open)} onEditClaim={(claim) => handleEditClaim(claim)} claim={currentClaim}/>)}
{isEditClaimOpen && currentClaim && (<ClaimEditModal isOpen={isEditClaimOpen} onClose={() => setIsEditClaimOpen(false)} onOpenChange={(open) => setIsEditClaimOpen(open)} claim={currentClaim} onSave={(updatedClaim) => {
updateClaimMutation.mutate(updatedClaim);
}}/>)}
{/* Pagination */}
{totalPages > 1 && (<div className="bg-white px-4 py-3 border-t border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
Showing {startItem}{endItem} of {claimsData?.totalCount || 0}{" "}
results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (<PaginationItem key={idx}>
{page === "..." ? (<span className="px-2 text-gray-500">...</span>) : (<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page}>
{page}
</PaginationLink>)}
</PaginationItem>))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>)}
</div>);
}

View File

@@ -0,0 +1,58 @@
import React from "react";
import { Input } from "@/components/ui/input";
export function RemarksField({ value, onChange, debounceMs = 250, // tweak (150300) if you like
}) {
const [local, setLocal] = React.useState(() => value);
// Track last prop we saw to detect true external changes
const lastPropRef = React.useRef(value);
React.useEffect(() => {
if (value !== lastPropRef.current && value !== local) {
// Only sync when parent changed from elsewhere
setLocal(value);
}
lastPropRef.current = value;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]); // (intentionally ignoring `local` in deps)
// Debounce: call parent onChange after user pauses typing
const timerRef = React.useRef(null);
const schedulePush = React.useCallback((next) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
onChange(next);
// update lastPropRef so the next parent echo won't resync over local
lastPropRef.current = next;
}, debounceMs);
}, [onChange, debounceMs]);
// Flush on unmount to avoid losing the last input
React.useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
onChange(local);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (<div className="space-y-2">
<Input id="remarks" placeholder="Paste clinical notes here" autoComplete="off" spellCheck={false} value={local} onChange={(e) => {
const next = e.target.value;
setLocal(next); // instant local update (no lag)
schedulePush(next); // debounced parent update
}} onBlur={() => {
// ensure latest text is pushed when the field loses focus
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (local !== lastPropRef.current) {
onChange(local);
lastPropRef.current = local;
}
}}/>
</div>);
}

View File

@@ -0,0 +1,161 @@
import React from "react";
import { Input } from "../ui/input";
export function toStatusLabel(s) {
if (!s)
return "Unknown";
if (s === "No_missing")
return "No Missing";
if (s === "endentulous")
return "Edentulous";
if (s === "Yes_missing")
return "Specify Missing";
// best-effort prettify
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
}
export function safeParseMissingTeeth(raw) {
if (!raw)
return {};
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object")
return parsed;
}
catch { }
return {};
}
if (typeof raw === "object")
return raw;
return {};
}
const PERM = new Set(Array.from({ length: 32 }, (_, i) => `T_${i + 1}`));
const PRIM = new Set(Array.from("ABCDEFGHIJKLMNOPQRST").map((ch) => `T_${ch}`));
export function splitTeeth(map) {
const permanent = [];
const primary = [];
for (const [k, v] of Object.entries(map)) {
if (!v)
continue;
if (PERM.has(k))
permanent.push({ name: k, v });
else if (PRIM.has(k))
primary.push({ name: k, v });
}
// stable, human-ish order
permanent.sort((a, b) => Number(a.name.slice(2)) - Number(b.name.slice(2)));
primary.sort((a, b) => a.name.localeCompare(b.name));
return { permanent, primary };
}
export function ToothChip({ name, v }) {
return (<span className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs bg-white">
<span className="font-medium">{name.replace("T_", "")}</span>
<span className="inline-flex h-5 w-5 items-center justify-center rounded border">
{v}
</span>
</span>);
}
/* ---------- parsing helpers ---------- */
const PERM_NUMBERS = new Set(Array.from({ length: 32 }, (_, i) => String(i + 1)));
const PRIM_LETTERS = new Set(Array.from("ABCDEFGHIJKLMNOPQRST"));
function normalizeToothToken(token) {
const t = token.trim().toUpperCase();
if (!t)
return null;
if (PERM_NUMBERS.has(t))
return t; // 1..32
if (t.length === 1 && PRIM_LETTERS.has(t))
return t; // A..T
return null;
}
function listToEntries(list, val) {
if (!list)
return [];
const seen = new Set();
return list
.split(/[,\s]+/g) // commas OR spaces
.map(normalizeToothToken) // uppercase + validate
.filter((t) => !!t)
.filter((t) => {
// de-duplicate within field
if (seen.has(t))
return false;
seen.add(t);
return true;
})
.map((t) => [`T_${t}`, val]);
}
/** Build map; 'O' overrides 'X' when duplicated across fields. */
export function mapFromLists(missingList, pullList) {
const map = {};
for (const [k, v] of listToEntries(missingList, "X"))
map[k] = v;
for (const [k, v] of listToEntries(pullList, "O"))
map[k] = v;
return map;
}
/** For initializing the inputs from an existing map (used only on mount or clear). */
export function listsFromMap(map) {
const missing = [];
const toPull = [];
for (const [k, v] of Object.entries(map || {})) {
if (v === "X")
missing.push(k.replace(/^T_/, ""));
else if (v === "O")
toPull.push(k.replace(/^T_/, ""));
}
const sort = (a, b) => {
const na = Number(a), nb = Number(b);
const an = !Number.isNaN(na), bn = !Number.isNaN(nb);
if (an && bn)
return na - nb;
if (an)
return -1;
if (bn)
return 1;
return a.localeCompare(b);
};
missing.sort(sort);
toPull.sort(sort);
return { missing: missing.join(", "), toPull: toPull.join(", ") };
}
/* ---------- UI ---------- */
export function MissingTeethSimple({ value, onChange, }) {
// initialize text inputs from incoming map
const init = React.useMemo(() => listsFromMap(value), []); // only on mount
const [missingField, setMissingField] = React.useState(init.missing);
const [pullField, setPullField] = React.useState(init.toPull);
// only resync when parent CLEARS everything (so your Clear All works)
React.useEffect(() => {
if (!value || Object.keys(value).length === 0) {
setMissingField("");
setPullField("");
}
}, [value]);
const recompute = (mStr, pStr) => {
onChange(mapFromLists(mStr, pStr));
};
return (<div className="space-y-3">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1">
{/* simple text label (no recharts Label) */}
<div className="text-sm font-medium">Tooth Number - Missing - X</div>
<Input placeholder="e.g. 1,2,A,B" value={missingField} onChange={(e) => {
const m = e.target.value.toUpperCase(); // keep uppercase in the field
setMissingField(m);
recompute(m, pullField);
}} aria-label="Tooth Numbers — Missing"/>
</div>
<div className="space-y-1">
<div className="text-sm font-medium">
Tooth Number - To be pulled - O
</div>
<Input placeholder="e.g. 4,5,D" value={pullField} onChange={(e) => {
const p = e.target.value.toUpperCase(); // keep uppercase in the field
setPullField(p);
recompute(missingField, p);
}} aria-label="Tooth Numbers — To be pulled"/>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,111 @@
import { Fragment, useEffect, useRef, useState } from "react";
export function Breadcrumbs({ path, onNavigate, }) {
const [openEllipsis, setOpenEllipsis] = useState(false);
const dropdownRef = useRef(null);
// close dropdown on outside click
useEffect(() => {
function onDocClick(e) {
if (!dropdownRef.current)
return;
if (!dropdownRef.current.contains(e.target)) {
setOpenEllipsis(false);
}
}
if (openEllipsis) {
document.addEventListener("mousedown", onDocClick);
}
return () => document.removeEventListener("mousedown", onDocClick);
}, [openEllipsis]);
// Render strategy: if path.length <= 4 show all; else show: first, ellipsis, last 2
const showAll = path.length <= 4;
const first = path[0];
const lastTwo = path.slice(Math.max(0, path.length - 2));
const middle = path.slice(1, Math.max(1, path.length - 2));
// utility classes
const inactiveChip = "inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm truncate max-w-[220px] bg-muted hover:bg-muted/80 text-muted-foreground";
const activeChip = "inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm truncate max-w-[220px] bg-primary/10 text-primary ring-1 ring-primary/20";
// render a chip with optional active flag
function Chip({ id, name, active, }) {
return (<button className={active ? activeChip : inactiveChip} onClick={() => onNavigate(id)} title={name ?? (id ? `Folder ${id}` : "My Cloud Storage")} aria-current={active ? "page" : undefined}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
<path d="M3 7h18v10H3z"/>
</svg>
<span className="truncate">
{name ?? (id ? `Folder ${id}` : "My Cloud Storage")}
</span>
</button>);
}
// small slash separator (visible between chips)
const Slash = () => <li className="text-muted-foreground px-1">/</li>;
return (
// Card-like background for the entire breadcrumb strip
<nav className="bg-card p-3 rounded-md shadow-sm" aria-label="breadcrumb">
<ol className="flex items-center gap-2 flex-wrap">
{/* Root chip */}
<li>
<button className={path.length === 0 ? activeChip : inactiveChip} onClick={() => onNavigate(null)} title="My Cloud Storage" aria-current={path.length === 0 ? "page" : undefined}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
<path d="M3 11.5L12 4l9 7.5V20a1 1 0 0 1-1 1h-4v-6H8v6H4a1 1 0 0 1-1-1v-8.5z"/>
</svg>
<span className="hidden sm:inline">My Cloud Storage</span>
</button>
</li>
{path.length > 0 && <Slash />}
{showAll ? (
// show all crumbs as chips with slashes between
path.map((p, idx) => (<Fragment key={String(p.id ?? idx)}>
<li>
<Chip id={p.id} name={p.name} active={idx === path.length - 1}/>
</li>
{idx !== path.length - 1 && <Slash />}
</Fragment>))) : (
// collapsed view: first, ellipsis dropdown, last two (with slashes)
<>
{first && (<>
<li>
<Chip id={first.id} name={first.name} active={false}/>
</li>
<Slash />
</>)}
<li>
<div className="relative" ref={dropdownRef}>
<button onClick={() => setOpenEllipsis((s) => !s)} aria-expanded={openEllipsis} className={inactiveChip} title="Show hidden path">
</button>
{/* dropdown for middle items */}
{openEllipsis && (<div className="absolute left-0 mt-2 w-56 bg-popover border rounded shadow z-50">
<ul className="p-2">
{middle.map((m) => (<li key={String(m.id)}>
<button className="w-full text-left px-2 py-1 rounded hover:bg-accent/5 text-sm text-muted-foreground" onClick={() => {
setOpenEllipsis(false);
onNavigate(m.id);
}}>
{m.name ?? `Folder ${m.id}`}
</button>
</li>))}
{middle.length === 0 && (<li>
<div className="px-2 py-1 text-sm text-muted-foreground">
No hidden folders
</div>
</li>)}
</ul>
</div>)}
</div>
</li>
<Slash />
{lastTwo.map((p, idx) => (<Fragment key={String(p.id ?? `tail-${idx}`)}>
<li>
<Chip id={p.id} name={p.name} active={idx === lastTwo.length - 1}/>
</li>
{idx !== lastTwo.length - 1 && <Slash />}
</Fragment>))}
</>)}
</ol>
</nav>);
}

View File

@@ -0,0 +1,210 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { Download, Maximize2, Minimize2, Trash2, X } from "lucide-react";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
export default function FilePreviewModal({ fileId, isOpen, onClose, onDeleted, }) {
const [loading, setLoading] = useState(false);
const [meta, setMeta] = useState(null);
const [blobUrl, setBlobUrl] = useState(null);
const [error, setError] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
useEffect(() => {
if (!isOpen || !fileId)
return;
let cancelled = false;
let createdUrl = null;
async function load() {
setLoading(true);
setError(null);
setMeta(null);
setBlobUrl(null);
try {
const metaRes = await apiRequest("GET", `/api/cloud-storage/files/${fileId}`);
const metaJson = await metaRes.json();
if (!metaRes.ok) {
throw new Error(metaJson?.message || "Failed to load file metadata");
}
if (cancelled)
return;
setMeta(metaJson.data);
const contentRes = await apiRequest("GET", `/api/cloud-storage/files/${fileId}/content`);
if (!contentRes.ok) {
let msg = `Preview request failed (${contentRes.status})`;
try {
const j = await contentRes.json();
msg = j?.message ?? msg;
}
catch (e) { }
throw new Error(msg);
}
const blob = await contentRes.blob();
if (cancelled)
return;
createdUrl = URL.createObjectURL(blob);
setBlobUrl(createdUrl);
}
catch (err) {
if (!cancelled)
setError(err?.message ?? String(err));
}
finally {
if (!cancelled)
setLoading(false);
}
}
load();
return () => {
cancelled = true;
if (createdUrl) {
URL.revokeObjectURL(createdUrl);
}
};
}, [isOpen, fileId]);
if (!isOpen)
return null;
const mime = meta?.mimeType ?? "";
async function handleDownload() {
if (!fileId)
return;
try {
const res = await apiRequest("GET", `/api/cloud-storage/files/${fileId}/download`);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j?.message || `Download failed (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = meta?.name ?? `file-${fileId}`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
catch (err) {
toast({
title: "Download failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
async function confirmDelete() {
if (!fileId)
return;
setIsDeleteOpen(false);
setDeleting(true);
try {
const res = await apiRequest("DELETE", `/api/cloud-storage/files/${fileId}`);
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(json?.message || `Delete failed (${res.status})`);
}
toast({
title: "Deleted",
description: `File "${meta?.name ?? `file-${fileId}`}" deleted.`,
});
// notify parent to refresh lists if they provided callback
if (typeof onDeleted === "function") {
try {
onDeleted();
}
catch (e) {
// ignore parent errors
}
}
// close modal
onClose();
}
catch (err) {
toast({
title: "Delete failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
finally {
setDeleting(false);
}
}
// container sizing classes
const containerBase = "bg-white rounded-md p-3 flex flex-col overflow-hidden shadow-xl";
const sizeClass = isFullscreen
? "w-[95vw] h-[95vh]"
: "w-[min(1200px,95vw)] h-[85vh]";
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
<div className={`${containerBase} ${sizeClass} max-w-full max-h-full`}>
{/* header */}
<div className="flex items-start justify-between gap-3 pb-2 border-b">
<div className="min-w-0">
<h3 className="text-lg font-semibold truncate">
{meta?.name ?? "Preview"}
</h3>
<div className="text-sm text-gray-500 truncate">
{meta?.mimeType ?? ""}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => setIsFullscreen((s) => !s)} title={isFullscreen ? "Exit fullscreen" : "Fullscreen"} className="p-2 rounded hover:bg-gray-100" aria-label="Toggle fullscreen">
{isFullscreen ? (<Minimize2 className="w-4 h-4"/>) : (<Maximize2 className="w-4 h-4"/>)}
</button>
<Button variant="ghost" onClick={handleDownload}>
<Download className="w-4 h-4"/>
</Button>
<Button variant="destructive" onClick={() => setIsDeleteOpen(true)} disabled={deleting}>
<Trash2 className="w-4 h-4"/>
</Button>
<Button onClick={onClose}>
{" "}
<X className="w-4 h-4"/>
</Button>
</div>
</div>
{/* body */}
<div className="flex-1 overflow-auto mt-3">
{/* loading / error */}
{loading && (<div className="w-full h-full flex items-center justify-center">
Loading preview
</div>)}
{error && <div className="text-red-600">{error}</div>}
{/* image */}
{!loading && !error && blobUrl && mime.startsWith("image/") && (<div className="flex items-center justify-center w-full h-full">
<img src={blobUrl} alt={meta?.name} className="max-w-full max-h-full object-contain" style={{ maxHeight: "calc(100vh - 200px)" }}/>
</div>)}
{/* pdf */}
{!loading &&
!error &&
blobUrl &&
(mime === "application/pdf" || mime.endsWith("/pdf")) && (<div className="w-full h-full">
<iframe src={blobUrl} title={meta?.name} className="w-full h-full border-0" style={{ minHeight: 400 }}/>
</div>)}
{/* fallback */}
{!loading &&
!error &&
blobUrl &&
!mime.startsWith("image/") &&
!mime.includes("pdf") && (<div className="p-4">
<p>Preview not available for this file type.</p>
<p className="mt-2">
<a href={blobUrl} target="_blank" rel="noopener noreferrer" className="underline">
Open raw
</a>
</p>
</div>)}
</div>
</div>
<DeleteConfirmationDialog isOpen={isDeleteOpen} entityName={meta?.name ?? undefined} onCancel={() => setIsDeleteOpen(false)} onConfirm={confirmDelete}/>
</div>);
}

View File

@@ -0,0 +1,463 @@
import React, { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, File as FileIcon, FileText, Image as ImageIcon, Trash2, Download, Edit3 as EditIcon, } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { MultipleFileUploadZone } from "@/components/file-upload/multiple-file-upload-zone";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { Menu, Item, contextMenu } from "react-contexify";
import "react-contexify/dist/ReactContexify.css";
import { useAuth } from "@/hooks/use-auth";
import { Pagination, PaginationContent, PaginationItem, PaginationPrevious, PaginationLink, PaginationNext, } from "@/components/ui/pagination";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
import { NewFolderModal } from "./new-folder-modal";
import { toast } from "@/hooks/use-toast";
import FilePreviewModal from "./file-preview-modal";
import { cloudSearchQueryKeyRoot } from "./search-bar";
// canonical root key for files list queries (per-parent)
export const cloudFilesQueryKeyRoot = ["cloud-files"];
/**
* Build a full query key for files list under a parent folder with page parameters.
* Example usage:
* cloudFilesQueryKeyBase(parentId, page, pageSize)
*/
export const cloudFilesQueryKeyBase = (parentId, page, pageSize) => [
"cloud-files",
parentId === null ? "null" : String(parentId),
page,
pageSize,
];
const FILES_LIMIT_DEFAULT = 20;
const MAX_FILE_MB = 10;
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;
function fileIcon(mime) {
if (!mime)
return <FileIcon className="h-6 w-6"/>;
if (mime.startsWith("image/"))
return <ImageIcon className="h-6 w-6"/>;
if (mime === "application/pdf" || mime.endsWith("/pdf"))
return <FileText className="h-6 w-6"/>;
return <FileIcon className="h-6 w-6"/>;
}
function FileThumbnail({ fileId, mime, name }) {
const [blobUrl, setBlobUrl] = useState(null);
useEffect(() => {
if (!mime?.startsWith("image/"))
return;
let url = null;
apiRequest("GET", `/api/cloud-storage/files/${fileId}/content`)
.then((res) => {
if (!res.ok)
throw new Error();
return res.blob();
})
.then((blob) => {
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch(() => { });
return () => { if (url)
URL.revokeObjectURL(url); };
}, [fileId, mime]);
if (mime?.startsWith("image/")) {
return blobUrl ? (<img src={blobUrl} alt={name ?? ""} className="w-full h-full object-cover rounded"/>) : (<div className="w-full h-full flex items-center justify-center text-gray-400">
<ImageIcon className="h-6 w-6"/>
</div>);
}
if (mime === "application/pdf" || mime?.endsWith("/pdf")) {
return (<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 rounded gap-1">
<FileText className="h-7 w-7 text-red-500"/>
<span className="text-[10px] font-bold text-red-500 tracking-wide">PDF</span>
</div>);
}
return (<div className="w-full h-full flex items-center justify-center text-gray-400">
{fileIcon(mime ?? undefined)}
</div>);
}
export default function FilesSection({ parentId, pageSize = FILES_LIMIT_DEFAULT, className, onFileOpen, }) {
const qc = useQueryClient();
const { user } = useAuth();
const userId = user?.id;
const [data, setData] = useState([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
// upload modal and ref
const [uploading, setUploading] = useState(false);
const [isUploadOpen, setIsUploadOpen] = useState(false);
const uploadRef = useRef(null);
// rename/delete
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [renameTargetId, setRenameTargetId] = useState(null);
const [renameInitial, setRenameInitial] = useState("");
// delete dialog
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
// preview modal
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [previewFileId, setPreviewFileId] = useState(null);
useEffect(() => {
loadPage(currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentId, currentPage]);
async function loadPage(page) {
setIsLoading(true);
try {
const offset = (page - 1) * pageSize;
const fid = parentId === null ? "null" : String(parentId);
const res = await apiRequest("GET", `/api/cloud-storage/items/files?parentId=${encodeURIComponent(fid)}&limit=${pageSize}&offset=${offset}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Failed to load files");
const rows = Array.isArray(json.data) ? json.data : [];
setData(rows);
const t = typeof json.totalCount === "number"
? json.totalCount
: typeof json.total === "number"
? json.total
: rows.length;
setTotal(t);
}
catch (err) {
setData([]);
setTotal(0);
toast({
title: "Failed to load files",
description: err?.message ?? String(err),
variant: "destructive",
});
}
finally {
setIsLoading(false);
}
}
function showMenu(e, file) {
e.preventDefault();
e.stopPropagation();
contextMenu.show({
id: "files-section-menu",
event: e.nativeEvent,
props: { file },
});
}
// rename
function openRename(file) {
setRenameTargetId(Number(file.id));
setRenameInitial(file.name ?? "");
setIsRenameOpen(true);
contextMenu.hideAll();
}
async function submitRename(newName) {
if (!renameTargetId)
return;
try {
const res = await apiRequest("PUT", `/api/cloud-storage/files/${renameTargetId}`, { name: newName });
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Rename failed");
setIsRenameOpen(false);
setRenameTargetId(null);
toast({ title: "File renamed" });
loadPage(currentPage);
qc.invalidateQueries({
queryKey: ["/api/cloud-storage/folders/recent", 1],
});
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
}
catch (err) {
toast({
title: "Rename failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
// delete
function openDelete(file) {
setDeleteTarget(file);
setIsDeleteOpen(true);
contextMenu.hideAll();
}
async function confirmDelete() {
if (!deleteTarget)
return;
try {
const res = await apiRequest("DELETE", `/api/cloud-storage/files/${deleteTarget.id}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Delete failed");
setIsDeleteOpen(false);
setDeleteTarget(null);
toast({ title: "File deleted" });
// reload current page (ensure page index valid)
loadPage(currentPage);
qc.invalidateQueries({
queryKey: ["/api/cloud-storage/folders/recent", 1],
});
// invalidate any cloud-files lists (so file lists refresh)
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
// invalidate any cloud-search queries so search results refresh
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
}
catch (err) {
toast({
title: "Delete failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
// download (context menu) - (fetch bytes from backend host via wrapper)
async function handleDownload(file) {
try {
const res = await apiRequest("GET", `/api/cloud-storage/files/${file.id}/download`);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j?.message || `Download failed (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name ?? `file-${file.id}`;
document.body.appendChild(a);
a.click();
a.remove();
// revoke after a bit
setTimeout(() => URL.revokeObjectURL(url), 5000);
contextMenu.hideAll();
}
catch (err) {
toast({
title: "Download failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
// upload: get files from MultipleFileUploadZone (imperative handle)
async function handleUploadSubmit() {
const files = uploadRef.current?.getFiles?.() ?? [];
if (!files.length) {
toast({
title: "No files selected",
description: "Please choose files to upload before clicking Upload.",
variant: "destructive",
});
return;
}
setUploading(true);
// pre-check all files and show errors / skip too-large files
const oversized = files.filter((f) => f.size > MAX_FILE_BYTES);
if (oversized.length) {
oversized.slice(0, 5).forEach((f) => toast({
title: "File too large",
description: `${f.name} is ${Math.round(f.size / 1024 / 1024)} MB — max ${MAX_FILE_MB} MB allowed.`,
variant: "destructive",
}));
// Remove oversized files from the upload list (upload the rest)
}
const toUpload = files.filter((f) => f.size <= MAX_FILE_BYTES);
if (toUpload.length === 0) {
// nothing to upload
return;
}
try {
for (const f of toUpload) {
const fid = parentId === null ? "null" : String(parentId);
const initRes = await apiRequest("POST", `/api/cloud-storage/folders/${encodeURIComponent(fid)}/files`, {
userId,
name: f.name,
mimeType: f.type || null,
expectedSize: f.size,
totalChunks: 1,
});
const initJson = await initRes.json();
const created = initJson?.data;
if (!created || typeof created.id !== "number")
throw new Error("Init failed");
const raw = await f.arrayBuffer();
// upload chunk
await apiRequest("POST", `/api/cloud-storage/files/${created.id}/chunks?seq=0`, raw);
// finalize
await apiRequest("POST", `/api/cloud-storage/files/${created.id}/complete`, {});
toast({ title: "Upload complete", description: f.name });
}
setIsUploadOpen(false);
loadPage(currentPage);
qc.invalidateQueries({
queryKey: ["/api/cloud-storage/folders/recent", 1],
});
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
}
catch (err) {
toast({
title: "Upload failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
finally {
setUploading(false);
}
}
// Pagination
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const endItem = Math.min(total, currentPage * pageSize);
// open preview (single click)
function openPreview(file) {
setPreviewFileId(Number(file.id));
setIsPreviewOpen(true);
contextMenu.hideAll();
}
return (<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Files</CardTitle>
<CardDescription>Manage Files in this folder</CardDescription>
</div>
<Button variant="default" className="inline-flex items-center px-4 py-2" onClick={() => setIsUploadOpen(true)}>
<Plus className="h-4 w-4 mr-2"/>
Upload
</Button>
</CardHeader>
<CardContent>
{isLoading ? (<div className="py-6 text-center">Loading...</div>) : (<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{data.map((file) => (<div key={file.id} className="rounded border hover:border-primary hover:shadow-sm cursor-pointer overflow-hidden transition-all" onContextMenu={(e) => showMenu(e, file)} onClick={() => openPreview(file)}>
<div className="h-28 w-full bg-gray-100">
<FileThumbnail fileId={Number(file.id)} mime={file.mimeType} name={file.name}/>
</div>
<div className="p-2">
<div className="text-sm font-medium truncate" title={file.name ?? ""}>
{file.name}
</div>
<div className="text-xs text-gray-400">
{(file.fileSize ?? 0).toString()} bytes
</div>
</div>
</div>))}
</div>
{/* pagination */}
{totalPages > 1 && (<div className="mt-4 pt-3 border-t border-gray-100">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground whitespace-nowrap">
Showing {startItem}{endItem} of {total} results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage((p) => Math.max(1, p - 1));
}} className={currentPage === 1
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (<PaginationItem key={idx}>
{page === "..." ? (<span className="px-2 text-gray-500">...</span>) : (<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page}>
{page}
</PaginationLink>)}
</PaginationItem>))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage((p) => Math.min(totalPages, p + 1));
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>)}
</>)}
</CardContent>
{/* context menu */}
<Menu id="files-section-menu" animation="fade">
<Item onClick={({ props }) => openRename(props.file)}>
<span className="flex items-center gap-2">
<EditIcon className="h-4 w-4"/> Rename
</span>
</Item>
<Item onClick={({ props }) => handleDownload(props.file)}>
<span className="flex items-center gap-2">
<Download className="h-4 w-4"/> Download
</span>
</Item>
<Item onClick={({ props }) => openDelete(props.file)}>
<span className="flex items-center gap-2 text-red-600">
<Trash2 className="h-4 w-4"/> Delete
</span>
</Item>
</Menu>
{/* upload modal using MultipleFileUploadZone (imperative handle) */}
{isUploadOpen && (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div className="bg-white p-6 rounded-md w-[90%] max-w-2xl">
<h3 className="text-lg font-semibold mb-4">Upload files</h3>
<MultipleFileUploadZone ref={uploadRef} acceptedFileTypes="application/pdf,image/*" maxFiles={10} maxFileSizeMB={10} maxFileSizeByType={{ "application/pdf": 10, "image/*": 5 }} isUploading={uploading}/>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setIsUploadOpen(false)}>
Cancel
</Button>
<Button onClick={handleUploadSubmit} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</Button>
</div>
</div>
</div>)}
{/* rename modal (reusing NewFolderModal for simplicity) */}
<NewFolderModal isOpen={isRenameOpen} initialName={renameInitial} title="Rename File" submitLabel="Rename" onClose={() => {
setIsRenameOpen(false);
setRenameTargetId(null);
}} onSubmit={submitRename}/>
{/* FIle Preview Modal */}
<FilePreviewModal fileId={previewFileId} isOpen={isPreviewOpen} onClose={() => {
setIsPreviewOpen(false);
setPreviewFileId(null);
}} onDeleted={() => {
// close preview
setIsPreviewOpen(false);
setPreviewFileId(null);
// reload this folder page
loadPage(currentPage);
// invalidate caches
qc.invalidateQueries({
queryKey: ["/api/cloud-storage/folders/recent", 1],
});
qc.invalidateQueries({
queryKey: cloudFilesQueryKeyRoot,
exact: false,
});
qc.invalidateQueries({
queryKey: cloudSearchQueryKeyRoot,
exact: false,
});
}}/>
{/* delete confirm */}
<DeleteConfirmationDialog isOpen={isDeleteOpen} entityName={deleteTarget?.name} onCancel={() => {
setIsDeleteOpen(false);
setDeleteTarget(null);
}} onConfirm={confirmDelete}/>
</Card>);
}

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useState } from "react";
import FolderSection from "@/components/cloud-storage/folder-section";
import FilesSection from "@/components/cloud-storage/files-section";
import { apiRequest } from "@/lib/queryClient";
import { Breadcrumbs } from "./bread-crumb";
export default function FolderPanel({ folderId, onClose, onViewChange, }) {
const [currentFolderId, setCurrentFolderId] = useState(folderId ?? null);
const [path, setPath] = useState([]);
const [isLoadingPath, setIsLoadingPath] = useState(false);
// When the panel opens to a different initial folder, sync and notify parent
useEffect(() => {
setCurrentFolderId(folderId ?? null);
onViewChange?.(folderId ?? null);
}, [folderId, onViewChange]);
// notify parent when viewed folder changes
useEffect(() => {
onViewChange?.(currentFolderId);
}, [currentFolderId, onViewChange]);
// whenever currentFolderId changes we load the ancestor path
useEffect(() => {
let mounted = true;
async function buildPath(fid) {
setIsLoadingPath(true);
try {
// We'll build path from root -> ... -> current. Since we don't know
// if backend provides a single endpoint for ancestry, we'll fetch
// current folder and walk parents until null. If fid is null then path is empty.
if (fid == null) {
if (mounted)
setPath([]);
return;
}
const collected = [];
let cursor = fid;
// keep a safety cap to avoid infinite loop in case of cycles
const MAX_DEPTH = 50;
let depth = 0;
while (cursor != null && depth < MAX_DEPTH) {
const res = await apiRequest("GET", `/api/cloud-storage/folders/${cursor}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Failed to fetch folder");
const folder = json?.data ?? json ?? null;
// normalize
const meta = {
id: folder?.id ?? null,
name: folder?.name ?? null,
parentId: folder?.parentId ?? null,
};
// prepend (we are walking up) then continue with parent
collected.push(meta);
cursor = meta.parentId;
depth += 1;
}
// collected currently top-down from current -> root. We need root->...->current
const rootToCurrent = collected.slice().reverse();
// we don't include the root (null) as an explicit item; Breadcrumbs shows "My Cloud Storage"
if (mounted)
setPath(rootToCurrent);
}
catch (err) {
console.error("buildPath error", err);
if (mounted)
setPath([]);
}
finally {
if (mounted)
setIsLoadingPath(false);
}
}
buildPath(currentFolderId);
return () => {
mounted = false;
};
}, [currentFolderId]);
// handler when child folder is clicked inside FolderSection
function handleChildSelect(childFolderId) {
// if user re-clicks current folder id as toggle, we still want to navigate into it.
setCurrentFolderId(childFolderId);
onViewChange?.(childFolderId); // keep page in sync
}
// navigate via breadcrumb (id may be null for root)
function handleNavigateTo(id) {
setCurrentFolderId(id);
onViewChange?.(id);
}
return (<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">
{currentFolderId == null
? "My Cloud Storage"
: `Folder : ${path[path.length - 1]?.name ?? currentFolderId}`}
</h2>
<div>
{onClose && (<button onClick={onClose} className="inline-flex items-center px-3 py-1.5 rounded-md text-sm hover:bg-gray-100">
Close
</button>)}
</div>
</div>
{/* Breadcrumb / path strip */}
<div>
{/* show breadcrumbs even if loading; breadcrumbs show 'My Cloud Storage' + path */}
<Breadcrumbs path={path} onNavigate={handleNavigateTo}/>
</div>
{/* stacked vertically: folders on top, files below */}
<div className="flex flex-col gap-6">
<div className="w-full">
{/* pass onSelect so FolderSection can tell the panel to navigate into a child */}
<FolderSection parentId={currentFolderId} onSelect={handleChildSelect}/>
</div>
<div className="w-full">
<FilesSection parentId={currentFolderId}/>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,288 @@
import React, { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Folder as FolderIcon, Plus, Trash2, Edit3 as EditIcon, } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { Menu, Item, contextMenu } from "react-contexify";
import "react-contexify/dist/ReactContexify.css";
import { useAuth } from "@/hooks/use-auth";
import { Pagination, PaginationContent, PaginationItem, PaginationPrevious, PaginationLink, PaginationNext, } from "@/components/ui/pagination";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
import { recentTopLevelFoldersQueryKey } from "./recent-top-level-folder-modal";
import { toast } from "@/hooks/use-toast";
export default function FolderSection({ parentId, pageSize = 10, className, onSelect, }) {
const qc = useQueryClient();
const { user } = useAuth();
const userId = user?.id;
const [data, setData] = useState([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [isNewOpen, setIsNewOpen] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [renameInitial, setRenameInitial] = useState("");
const [renameTargetId, setRenameTargetId] = useState(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [selectedId, setSelectedId] = useState(null);
// reset selectedId and page when parent changes
useEffect(() => {
setSelectedId(null);
setCurrentPage(1);
}, [parentId]);
// load page
useEffect(() => {
loadPage(currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentId, currentPage]);
async function loadPage(page) {
setIsLoading(true);
try {
const offset = (page - 1) * pageSize;
const pid = parentId === null ? "null" : String(parentId);
const res = await apiRequest("GET", `/api/cloud-storage/items/folders?parentId=${encodeURIComponent(pid)}&limit=${pageSize}&offset=${offset}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Failed to load folders");
const rows = Array.isArray(json.data) ? json.data : [];
setData(rows);
const t = typeof json.total === "number"
? json.total
: typeof json.totalCount === "number"
? json.totalCount
: rows.length;
setTotal(t);
}
catch (err) {
setData([]);
setTotal(0);
toast({
title: "Failed to load folders",
description: err?.message ?? String(err),
variant: "destructive",
});
}
finally {
setIsLoading(false);
}
}
// tile click toggles selection
function handleTileClick(id) {
const next = selectedId === id ? null : id;
setSelectedId(next);
onSelect?.(next);
contextMenu.hideAll();
}
// right-click menu via react-contexify
function showMenu(e, folder) {
e.preventDefault();
e.stopPropagation();
contextMenu.show({
id: `folder-section-menu`,
event: e.nativeEvent,
props: { folder },
});
}
// create folder
async function handleCreate(name) {
if (!userId) {
toast({
title: "Not signed in",
description: "Please sign in to create folders.",
variant: "destructive",
});
return; // caller should ensure auth
}
try {
const res = await apiRequest("POST", "/api/cloud-storage/folders", {
userId,
name,
parentId,
});
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Create failed");
setIsNewOpen(false);
toast({ title: "Folder created" });
// refresh this page and top-level recent
loadPage(1);
setCurrentPage(1);
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
}
catch (err) {
toast({
title: "Create failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
// rename
function openRename(folder) {
setRenameTargetId(Number(folder.id));
setRenameInitial(folder.name ?? "");
setIsRenameOpen(true);
contextMenu.hideAll();
}
async function submitRename(newName) {
if (!renameTargetId)
return;
try {
const res = await apiRequest("PUT", `/api/cloud-storage/folders/${renameTargetId}`, { name: newName });
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Rename failed");
setIsRenameOpen(false);
setRenameTargetId(null);
toast({ title: "Folder renamed" });
loadPage(currentPage);
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
}
catch (err) {
toast({
title: "Rename failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
// delete
function openDelete(folder) {
setDeleteTarget(folder);
setIsDeleteOpen(true);
contextMenu.hideAll();
}
async function confirmDelete() {
if (!deleteTarget)
return;
try {
const res = await apiRequest("DELETE", `/api/cloud-storage/folders/${deleteTarget.id}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Delete failed");
// deselect if needed
if (selectedId === deleteTarget.id) {
setSelectedId(null);
onSelect?.(null);
}
setIsDeleteOpen(false);
setDeleteTarget(null);
toast({ title: "Folder deleted" });
// reload current page (if empty page and not first, move back)
const maybePage = Math.max(1, currentPage);
loadPage(maybePage);
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
}
catch (err) {
toast({
title: "Delete failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const endItem = Math.min(total, currentPage * pageSize);
return (<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Folders</CardTitle>
<CardDescription>Manage all its Child folders</CardDescription>
</div>
<Button variant="default" className="inline-flex items-center px-4 py-2" onClick={() => setIsNewOpen(true)}>
<Plus className="h-4 w-4 mr-2"/>
New Folder
</Button>
</CardHeader>
<CardContent>
{isLoading ? (<div className="py-6 text-center">Loading...</div>) : (<>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-2">
{data.map((f) => {
const isSelected = selectedId === f.id;
return (<div key={f.id} className="flex">
<div role="button" tabIndex={0} onClick={() => handleTileClick(Number(f.id))} onContextMenu={(e) => showMenu(e, f)} className={"w-full flex items-center gap-3 p-2 rounded-lg hover:bg-gray-100 cursor-pointer " +
(isSelected ? "ring-2 ring-blue-400 bg-blue-50" : "")}>
<FolderIcon className="h-6 w-6 text-yellow-500"/>
<div className="text-sm truncate">{f.name}</div>
</div>
</div>);
})}
</div>
{/* Pagination inside card */}
{totalPages > 1 && (<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground whitespace-nowrap">
Showing {startItem}{endItem} of {total} results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage((p) => Math.max(1, p - 1));
}} className={currentPage === 1
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (<PaginationItem key={idx}>
{page === "..." ? (<span className="px-2 text-gray-500">...</span>) : (<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page}>
{page}
</PaginationLink>)}
</PaginationItem>))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage((p) => Math.min(totalPages, p + 1));
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>)}
</>)}
</CardContent>
{/* react-contexify menu */}
<Menu id="folder-section-menu" animation="fade">
<Item onClick={({ props }) => openRename(props.folder)}>
<span className="flex items-center gap-2">
<EditIcon className="h-4 w-4"/> Rename
</span>
</Item>
<Item onClick={({ props }) => openDelete(props.folder)}>
<span className="flex items-center gap-2 text-red-600">
<Trash2 className="h-4 w-4"/> Delete
</span>
</Item>
</Menu>
{/* Modals */}
<NewFolderModal isOpen={isNewOpen} onClose={() => setIsNewOpen(false)} onSubmit={handleCreate}/>
<NewFolderModal isOpen={isRenameOpen} initialName={renameInitial} title="Rename Folder" submitLabel="Rename" onClose={() => {
setIsRenameOpen(false);
setRenameTargetId(null);
}} onSubmit={submitRename}/>
<DeleteConfirmationDialog isOpen={isDeleteOpen} entityName={deleteTarget?.name} onCancel={() => {
setIsDeleteOpen(false);
setDeleteTarget(null);
}} onConfirm={confirmDelete}/>
</Card>);
}

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
export function NewFolderModal({ isOpen, initialName = "", title = "New Folder", submitLabel = "Create", onClose, onSubmit, }) {
const [name, setName] = useState(initialName);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
setName(initialName);
}, [initialName, isOpen]);
if (!isOpen)
return null;
return (<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => {
if (!isSubmitting)
onClose();
}}/>
<div className="relative w-full max-w-md mx-4 bg-white rounded-lg shadow-lg">
<div className="p-4 border-b">
<h3 className="text-lg font-medium">{title}</h3>
</div>
<form onSubmit={async (e) => {
e.preventDefault();
if (!name.trim())
return;
try {
setIsSubmitting(true);
await onSubmit(name.trim());
}
finally {
setIsSubmitting(false);
}
}}>
<div className="p-4 space-y-3">
<label className="block text-sm font-medium">Folder name</label>
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded-md border px-3 py-2" placeholder="Enter folder name" disabled={isSubmitting}/>
</div>
<div className="flex items-center justify-end gap-2 p-4 border-t">
<Button variant="ghost" type="button" onClick={() => !isSubmitting && onClose()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !name.trim()}>
{isSubmitting ? "Saving..." : submitLabel}
</Button>
</div>
</form>
</div>
</div>);
}

View File

@@ -0,0 +1,244 @@
import React, { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "@/components/ui/card";
import { EditIcon, Folder, Trash2 } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
import { Pagination, PaginationContent, PaginationItem, PaginationPrevious, PaginationLink, PaginationNext, } from "@/components/ui/pagination";
import { useToast } from "@/hooks/use-toast";
import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { Menu, Item, contextMenu } from "react-contexify";
import "react-contexify/dist/ReactContexify.css";
export const recentTopLevelFoldersQueryKey = (page) => [
"/api/cloud-storage/folders/recent",
page,
];
export default function RecentTopLevelFoldersCard({ pageSize = 10, initialPage = 1, className, onSelect, }) {
const [currentPage, setCurrentPage] = useState(initialPage);
const [selectedFolderId, setSelectedFolderId] = useState(null);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [renameInitialName, setRenameInitialName] = useState("");
const [renameTargetId, setRenameTargetId] = useState(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const qc = useQueryClient();
const { toast } = useToast();
const { data: recentFoldersData, isLoading: isLoadingRecentFolders, refetch, } = useQuery({
queryKey: recentTopLevelFoldersQueryKey(currentPage),
queryFn: async () => {
const offset = (currentPage - 1) * pageSize;
const res = await apiRequest("GET", `/api/cloud-storage/folders/recent?limit=${pageSize}&offset=${offset}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Failed to load recent folders");
const data = Array.isArray(json.data) ? json.data : [];
const totalCount = typeof json.totalCount === "number"
? json.totalCount
: typeof json.total === "number"
? json.total
: data.length;
return { data, totalCount };
},
});
const data = recentFoldersData?.data ?? [];
const totalCount = recentFoldersData?.totalCount ?? data.length;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const startItem = totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const endItem = Math.min(totalCount, currentPage * pageSize);
// toggle selection: select if different, deselect if same
function handleTileClick(id) {
if (selectedFolderId === id) {
setSelectedFolderId(null);
onSelect?.(null);
}
else {
setSelectedFolderId(id);
onSelect?.(id);
}
// close any open context menu
contextMenu.hideAll();
}
// show react-contexify menu on right-click
function handleContextMenu(e, folder) {
e.preventDefault();
e.stopPropagation();
contextMenu.show({
id: "recent-folder-context-menu",
event: e.nativeEvent,
props: { folder },
});
}
// rename flow
function openRename(folder) {
setRenameTargetId(Number(folder.id));
setRenameInitialName(folder.name ?? "");
setIsRenameOpen(true);
contextMenu.hideAll();
}
async function handleRenameSubmit(newName) {
if (!renameTargetId)
return;
try {
const res = await apiRequest("PUT", `/api/cloud-storage/folders/${renameTargetId}`, {
name: newName,
});
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Failed to rename folder");
toast({ title: "Folder renamed" });
setIsRenameOpen(false);
setRenameTargetId(null);
// refresh current page & first page
qc.invalidateQueries({
queryKey: recentTopLevelFoldersQueryKey(currentPage),
});
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
await refetch();
}
catch (err) {
toast({ title: "Error", description: err?.message || String(err) });
}
}
// delete flow
function openDelete(folder) {
setDeleteTarget(folder);
setIsDeleteOpen(true);
contextMenu.hideAll();
}
async function handleDeleteConfirm() {
if (!deleteTarget)
return;
const id = deleteTarget.id;
try {
const res = await apiRequest("DELETE", `/api/cloud-storage/folders/${id}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Failed to delete folder");
toast({ title: "Folder deleted" });
setIsDeleteOpen(false);
setDeleteTarget(null);
// if the deleted folder was selected, deselect it and notify parent
if (selectedFolderId === id) {
setSelectedFolderId(null);
onSelect?.(null);
}
// refresh pages
qc.invalidateQueries({
queryKey: recentTopLevelFoldersQueryKey(currentPage),
});
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
await refetch();
}
catch (err) {
toast({ title: "Error", description: err?.message || String(err) });
}
}
return (<Card className={className}>
<CardHeader>
<CardTitle>Recent Folders</CardTitle>
<CardDescription>
Most recently updated top-level folders.
</CardDescription>
</CardHeader>
<CardContent className="py-3">
{isLoadingRecentFolders ? (<div className="py-6 text-center">Loading...</div>) : (<>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-2">
{data.map((f) => {
const isSelected = selectedFolderId === Number(f.id);
return (<div key={f.id} className="flex">
<div role="button" tabIndex={0} onClick={() => handleTileClick(Number(f.id))} onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
handleTileClick(Number(f.id));
}} onContextMenu={(e) => handleContextMenu(e, f)} className={"w-full flex items-center gap-3 p-2 rounded-lg hover:bg-gray-100 cursor-pointer focus:outline-none " +
(isSelected ? "ring-2 ring-blue-400 bg-blue-50" : "")} style={{ minHeight: 44 }}>
<Folder className="h-8 w-8 text-yellow-500 flex-shrink-0"/>
<div className="text-sm truncate">{f.name}</div>
</div>
</div>);
})}
</div>
{totalPages > 1 && (<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="text-sm text-muted-foreground whitespace-nowrap">
Showing {startItem}{endItem} of {totalCount} results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}} className={currentPage === 1
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (<PaginationItem key={idx}>
{page === "..." ? (<span className="px-2 text-gray-500">...</span>) : (<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page}>
{page}
</PaginationLink>)}
</PaginationItem>))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>)}
</>)}
</CardContent>
{/* react-contexify Menu (single shared menu) */}
<Menu id="recent-folder-context-menu" animation="fade">
<Item onClick={({ props }) => {
const folder = props?.folder;
if (folder)
openRename(folder);
}}>
<span className="flex items-center gap-2">
<EditIcon className="h-4 w-4"/> Rename
</span>
</Item>
<Item onClick={({ props }) => {
const folder = props?.folder;
if (folder)
openDelete(folder);
}}>
<span className="flex items-center gap-2 text-red-600">
<Trash2 className="h-4 w-4"/>
Delete
</span>
</Item>
</Menu>
{/* Rename modal (reuses NewFolderModal) */}
<NewFolderModal isOpen={isRenameOpen} initialName={renameInitialName} title="Rename Folder" submitLabel="Rename" onClose={() => {
setIsRenameOpen(false);
setRenameTargetId(null);
}} onSubmit={async (name) => {
await handleRenameSubmit(name);
}}/>
{/* Delete confirmation */}
<DeleteConfirmationDialog isOpen={isDeleteOpen} entityName={deleteTarget?.name} onCancel={() => {
setIsDeleteOpen(false);
setDeleteTarget(null);
}} onConfirm={handleDeleteConfirm}/>
</Card>);
}

View File

@@ -0,0 +1,293 @@
import React, { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Folder as FolderIcon, File as FileIcon, Search as SearchIcon, Clock as ClockIcon, ChevronLeft, ChevronRight, } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
/**
* Canonical query keys
*/
export const cloudSearchQueryKeyRoot = ["cloud-search"];
export const cloudSearchQueryKeyBase = (q, searchTarget, typeFilter, page) => ["cloud-search", q, searchTarget, typeFilter, page];
export default function CloudSearchBar({ onOpenFolder = (id) => { }, onSelectFile = (fileId) => { }, }) {
const [q, setQ] = useState("");
const [searchTarget, setSearchTarget] = useState("both");
const [typeFilter, setTypeFilter] = useState("any");
const [page, setPage] = useState(1);
const [limit] = useState(10);
const debounceMs = 600;
const [debouncedQ, setDebouncedQ] = useState(q);
// debounce input
useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q.trim()), debounceMs);
return () => clearTimeout(t);
}, [q, debounceMs]);
function typeParamFromFilter(filter) {
if (filter === "any")
return undefined;
if (filter === "images")
return "image";
if (filter === "pdf")
return "application/pdf";
return filter;
}
// fetcher used by useQuery
async function fetchSearch() {
const query = debouncedQ ?? "";
if (!query)
return { results: [], total: 0 };
const offset = (page - 1) * limit;
const typeParam = typeParamFromFilter(typeFilter);
// helper: call files endpoint
async function callFiles() {
const tQuery = typeParam ? `&type=${encodeURIComponent(typeParam)}` : "";
const res = await apiRequest("GET", `/api/cloud-storage/search/files?q=${encodeURIComponent(query)}${tQuery}&limit=${limit}&offset=${offset}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "File search failed");
const mapped = (json.data || []).map((d) => ({
id: d.id,
name: d.name,
kind: "file",
mimeType: d.mimeType,
fileSize: d.fileSize,
folderId: d.folderId ?? null,
createdAt: d.createdAt,
}));
return { mapped, total: json.totalCount ?? mapped.length };
}
// helper: call folders endpoint
async function callFolders() {
const res = await apiRequest("GET", `/api/cloud-storage/search/folders?q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`);
const json = await res.json();
if (!res.ok)
throw new Error(json?.message || "Folder search failed");
const mapped = (json.data || []).map((d) => ({
id: d.id,
name: d.name,
kind: "folder",
folderId: d.parentId ?? null,
}));
// enforce top-level folders only when searching folders specifically
// (if the API already filters, this is harmless)
return { mapped, total: json.totalCount ?? mapped.length };
}
// Decide which endpoints to call
if (searchTarget === "filename") {
const f = await callFiles();
return { results: f.mapped, total: f.total };
}
else if (searchTarget === "foldername") {
const fo = await callFolders();
// filter top-level only (parentId === null)
const topLevel = fo.mapped.filter((r) => r.folderId == null);
return { results: topLevel, total: fo.total };
}
else {
// both: call both and combine (folders first, then files), but keep page limit
const [filesRes, foldersRes] = await Promise.all([
callFiles(),
callFolders(),
]);
// folders restrict to top-level
const foldersTop = foldersRes.mapped.filter((r) => r.folderId == null);
const combined = [...foldersTop, ...filesRes.mapped].slice(0, limit);
const combinedTotal = foldersRes.total + filesRes.total;
return { results: combined, total: combinedTotal };
}
}
// react-query: key depends on debouncedQ, searchTarget, typeFilter, page
const queryKey = useMemo(() => cloudSearchQueryKeyBase(debouncedQ, searchTarget, typeFilter, page), [debouncedQ, searchTarget, typeFilter, page]);
const { data, isFetching, error } = useQuery({
queryKey,
queryFn: fetchSearch,
enabled: debouncedQ.length > 0,
staleTime: 0,
});
// sync local UI state with query data
const results = data?.results ?? [];
const total = data?.total ?? 0;
const loading = isFetching;
const errMsg = error ? (error?.message ?? String(error)) : null;
// persist recent terms & matches when new results arrive
useEffect(() => {
if (!debouncedQ)
return;
// recent terms
try {
const raw = localStorage.getItem("cloud_search_recent_terms");
const prev = raw ? JSON.parse(raw) : [];
const term = debouncedQ;
const copy = [term, ...prev.filter((t) => t !== term)].slice(0, 10);
localStorage.setItem("cloud_search_recent_terms", JSON.stringify(copy));
}
catch { }
// recent matches snapshot
try {
const rawMatches = localStorage.getItem("cloud_search_recent_matches");
const prevMatches = rawMatches
? JSON.parse(rawMatches)
: {};
const snapshot = results;
const copy = { ...prevMatches, [debouncedQ]: snapshot };
localStorage.setItem("cloud_search_recent_matches", JSON.stringify(copy));
}
catch { }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, debouncedQ]);
// load recentTerms & recentMatches from storage for initial UI
const [recentTerms, setRecentTerms] = useState(() => {
try {
const raw = localStorage.getItem("cloud_search_recent_terms");
return raw ? JSON.parse(raw) : [];
}
catch {
return [];
}
});
const [recentMatches, setRecentMatches] = useState(() => {
try {
const raw = localStorage.getItem("cloud_search_recent_matches");
return raw ? JSON.parse(raw) : {};
}
catch {
return {};
}
});
// update recentTerms/recentMatches UI copies whenever localStorage changes (best-effort)
useEffect(() => {
try {
const raw = localStorage.getItem("cloud_search_recent_terms");
setRecentTerms(raw ? JSON.parse(raw) : []);
}
catch { }
try {
const raw = localStorage.getItem("cloud_search_recent_matches");
setRecentMatches(raw ? JSON.parse(raw) : {});
}
catch { }
}, [data]); // refresh small UX cache when new data arrives
// reset page when q or filters change (like before)
useEffect(() => setPage(1), [debouncedQ, searchTarget, typeFilter]);
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / limit)), [total, limit]);
function onClear() {
setQ("");
// the query will auto-disable when debouncedQ is empty
}
return (<div className="bg-card p-4 rounded-2xl shadow-sm">
<div className="flex flex-col md:flex-row gap-3 md:items-center">
<div className="flex items-center gap-2 flex-1">
<SearchIcon className="h-5 w-5 text-muted-foreground"/>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search files and folders..." aria-label="Search files and folders" className="flex-1"/>
<Button variant="ghost" onClick={() => onClear()}>
Clear
</Button>
</div>
<div className="flex items-center gap-2">
<Select onValueChange={(v) => setSearchTarget(v)} value={searchTarget}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Search target"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="filename">Filename only</SelectItem>
<SelectItem value="foldername">
Folder name (top-level)
</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={(v) => setTypeFilter(v)} value={typeFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Type"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any type</SelectItem>
<SelectItem value="images">Images</SelectItem>
<SelectItem value="pdf">PDFs</SelectItem>
<SelectItem value="video">Videos</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setPage((p) => p)}>Search</Button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="mb-2 text-sm font-semibold flex items-center gap-2">
<ClockIcon className="h-4 w-4"/> Recent searches
</h4>
<div className="flex flex-wrap gap-2">
{recentTerms.length ? (recentTerms.map((t) => (<motion.button key={t} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} className="px-3 py-1 rounded-full bg-muted text-sm" onClick={() => setQ(t)}>
{t}
</motion.button>))) : (<div className="text-sm text-muted-foreground">
No recent searches
</div>)}
</div>
</div>
<div>
<h4 className="mb-2 text-sm font-semibold">Results</h4>
<div className="bg-background rounded-md p-2 max-h-72 overflow-auto">
<AnimatePresence>
{loading && (<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="p-4 text-center text-sm">
Searching...
</motion.div>)}
{!loading && errMsg && (<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="p-4 text-sm text-destructive">
{errMsg}
</motion.div>)}
{!loading && !results.length && debouncedQ && !errMsg && (<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="p-4 text-sm text-muted-foreground">
No results for "{debouncedQ}"
</motion.div>)}
{!loading &&
results.map((r) => (<motion.div key={`${r.kind}-${r.id}`} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="p-2 rounded hover:bg-muted/50 flex items-center gap-3 cursor-pointer" onClick={() => {
if (r.kind === "folder")
onOpenFolder(r.id);
else
onSelectFile(r.id);
}}>
<div className="w-8 h-8 flex items-center justify-center rounded bg-muted">
{r.kind === "folder" ? (<FolderIcon className="h-4 w-4"/>) : (<FileIcon className="h-4 w-4"/>)}
</div>
<div className="flex-1 min-w-0">
<div className="truncate font-medium">{r.name}</div>
<div className="text-xs text-muted-foreground truncate">
{r.kind === "file" ? (r.mimeType ?? "file") : "Folder"}
</div>
</div>
{r.kind === "file" && r.fileSize != null && (<div className="text-xs text-muted-foreground">
{String(r.fileSize)}
</div>)}
</motion.div>))}
</AnimatePresence>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{total} result(s)
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
<ChevronLeft className="h-4 w-4"/>
</Button>
<div className="text-sm">
{page} / {totalPages}
</div>
<Button size="sm" variant="ghost" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
<ChevronRight className="h-4 w-4"/>
</Button>
</div>
</div>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,179 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog";
import { FolderOpen, HardDrive, Trash2 } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { FolderBrowserModal } from "./folder-browser-modal";
export function BackupDestinationManager() {
const { toast } = useToast();
const [path, setPath] = useState("");
const [deleteId, setDeleteId] = useState(null);
const [browserOpen, setBrowserOpen] = useState(false);
// ==============================
// Queries
// ==============================
const { data: destinations = [] } = useQuery({
queryKey: ["/db/destination"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/destination");
return res.json();
},
});
const { data: usbSettingData } = useQuery({
queryKey: ["/db/usb-backup-setting"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/usb-backup-setting");
return res.json();
},
});
const usbBackupEnabled = usbSettingData?.usbBackupEnabled ?? false;
const usbBackupHour = usbSettingData?.usbBackupHour ?? 21;
const usbToggleMutation = useMutation({
mutationFn: async (patch) => {
const res = await apiRequest("PUT", "/api/database-management/usb-backup-setting", patch);
return res.json();
},
onSuccess: (data) => {
queryClient.setQueryData(["/db/usb-backup-setting"], data);
toast({ title: "Setting Saved" });
},
onError: () => {
toast({
title: "Error",
description: "Failed to update USB backup setting.",
variant: "destructive",
});
},
});
// ==============================
// Mutations
// ==============================
const saveMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/destination", { path });
if (!res.ok)
throw new Error((await res.json()).error);
},
onSuccess: () => {
toast({ title: "Backup destination saved" });
setPath("");
queryClient.invalidateQueries({ queryKey: ["/db/destination"] });
},
});
const deleteMutation = useMutation({
mutationFn: async (id) => {
await apiRequest("DELETE", `/api/database-management/destination/${id}`);
},
onSuccess: () => {
toast({ title: "Backup destination deleted" });
queryClient.invalidateQueries({ queryKey: ["/db/destination"] });
setDeleteId(null);
},
});
const backupNowMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/backup-path");
if (!res.ok) {
const body = await res.json();
throw new Error(body.details || body.error || "Backup failed");
}
return res.json();
},
onSuccess: (data) => {
toast({ title: "Backup complete", description: `Saved: ${data.filename}` });
queryClient.invalidateQueries({ queryKey: ["/db/status"] });
},
onError: (err) => {
toast({ title: "Backup failed", description: err.message, variant: "destructive" });
},
});
// ==============================
// Folder browser
// ==============================
const handleFolderSelect = (selectedPath) => {
setPath(selectedPath);
};
// ==============================
// UI
// ==============================
return (<Card>
<CardHeader>
<CardTitle>External Backup Destination</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Switch id="usb-backup-toggle" checked={usbBackupEnabled} onCheckedChange={(checked) => usbToggleMutation.mutate({ usbBackupEnabled: checked })} disabled={usbToggleMutation.isPending}/>
<label htmlFor="usb-backup-toggle" className="text-sm font-medium text-gray-700 cursor-pointer select-none">
USB Backup
</label>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">at</label>
<select className="border rounded px-2 py-1 text-sm text-gray-700 bg-white" value={usbBackupHour} onChange={(e) => usbToggleMutation.mutate({ usbBackupHour: Number(e.target.value) })}>
{Array.from({ length: 24 }, (_, h) => {
const label = h === 0 ? "12:00 AM" : h < 12 ? `${h}:00 AM` : h === 12 ? "12:00 PM" : `${h - 12}:00 PM`;
return <option key={h} value={h}>{label}</option>;
})}
</select>
</div>
</div>
<p className="text-sm text-gray-500">
Enter the root path of your USB drive below. The app will automatically back up to the{" "}
<span className="font-medium text-gray-700">USB Backup</span> folder inside it at the scheduled time when the toggle is on.
</p>
<div className="flex gap-2">
<Input placeholder="/media/usb-drive or D:\\Backups" value={path} onChange={(e) => setPath(e.target.value)}/>
<Button variant="outline" onClick={() => setBrowserOpen(true)} title="Browse folders">
<FolderOpen className="h-4 w-4"/>
</Button>
</div>
<FolderBrowserModal open={browserOpen} onClose={() => setBrowserOpen(false)} onSelect={handleFolderSelect}/>
<Button onClick={() => saveMutation.mutate()} disabled={!path || saveMutation.isPending}>
Save Destination
</Button>
<div className="space-y-2">
{destinations.map((d) => (<div key={d.id} className="flex justify-between items-center border rounded p-2">
<span className="text-sm text-gray-700">{d.path}</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => backupNowMutation.mutate()} disabled={backupNowMutation.isPending} title="Backup now to this destination">
<HardDrive className="h-4 w-4 mr-1"/>
{backupNowMutation.isPending ? "Backing up..." : "Backup Now"}
</Button>
<Button size="sm" variant="destructive" onClick={() => setDeleteId(d.id)}>
<Trash2 className="h-4 w-4"/>
</Button>
</div>
</div>))}
</div>
{/* Confirm delete dialog */}
<AlertDialog open={deleteId !== null}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup destination?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the destination and stop automatic backups.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteId(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteId && deleteMutation.mutate(deleteId)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import { Folder, FolderOpen, ChevronLeft, Loader2 } from "lucide-react";
export function FolderBrowserModal({ open, onClose, onSelect }) {
const [browsePath, setBrowsePath] = useState("/");
const [selected, setSelected] = useState("/");
const { data, isLoading, isError } = useQuery({
queryKey: ["/db/browse", browsePath],
queryFn: async () => {
const res = await apiRequest("GET", `/api/database-management/browse?path=${encodeURIComponent(browsePath)}`);
if (!res.ok)
throw new Error((await res.json()).error);
return res.json();
},
enabled: open,
});
const handleNavigate = (path) => {
setSelected(path);
setBrowsePath(path);
};
const handleConfirm = () => {
onSelect(selected);
onClose();
};
return (<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Select Folder</DialogTitle>
</DialogHeader>
{/* Current path breadcrumb */}
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 font-mono break-all">
{data?.current ?? browsePath}
</div>
{/* Back button */}
{data?.parent && (<Button variant="ghost" size="sm" className="justify-start text-gray-600" onClick={() => handleNavigate(data.parent)}>
<ChevronLeft className="h-4 w-4 mr-1"/>
Back
</Button>)}
{/* Directory list */}
<div className="border rounded-md overflow-y-auto max-h-64">
{isLoading && (<div className="flex items-center justify-center py-8 text-gray-400">
<Loader2 className="h-5 w-5 animate-spin mr-2"/>
Loading...
</div>)}
{isError && (<p className="text-sm text-red-500 p-4">Cannot read this directory.</p>)}
{!isLoading && !isError && data?.dirs.length === 0 && (<p className="text-sm text-gray-400 p-4">No sub-folders here.</p>)}
{!isLoading &&
!isError &&
data?.dirs.map((dir) => (<button key={dir.path} className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-gray-50 transition-colors ${selected === dir.path ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-700"}`} onClick={() => setSelected(dir.path)} onDoubleClick={() => handleNavigate(dir.path)}>
{selected === dir.path ? (<FolderOpen className="h-4 w-4 shrink-0 text-blue-500"/>) : (<Folder className="h-4 w-4 shrink-0 text-yellow-500"/>)}
{dir.name}
</button>))}
</div>
<p className="text-xs text-gray-400">
Single-click to select · Double-click to open
</p>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleConfirm}>
Select Folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>);
}

View File

@@ -0,0 +1,116 @@
import { useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Upload, UploadCloud } from "lucide-react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
export function ImportDatabaseSection() {
const { toast } = useToast();
const fileInputRef = useRef(null);
const [selectedFile, setSelectedFile] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const restoreMutation = useMutation({
mutationFn: async (file) => {
const formData = new FormData();
formData.append("file", file);
const token = localStorage.getItem("token");
const res = await fetch("/api/database-management/restore", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || "Restore failed");
}
return res.json();
},
onSuccess: () => {
toast({
title: "Database Restored",
description: "Database restored successfully. Redirecting to login...",
});
setSelectedFile(null);
if (fileInputRef.current)
fileInputRef.current.value = "";
// Clear auth token and reload so the user re-authenticates against the
// restored database. This is necessary because the restored data may have
// a different userId than the current JWT, which would cause all
// user-scoped queries to return empty results.
setTimeout(() => {
localStorage.removeItem("token");
window.location.href = "/";
}, 2000);
},
onError: (err) => {
toast({
title: "Restore Failed",
description: err.message,
variant: "destructive",
});
},
});
const handleFileChange = (e) => {
const file = e.target.files?.[0] ?? null;
setSelectedFile(file);
};
const handleImportClick = () => {
if (!selectedFile)
return;
setConfirmOpen(true);
};
const handleConfirm = () => {
setConfirmOpen(false);
if (selectedFile)
restoreMutation.mutate(selectedFile);
};
return (<>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<UploadCloud className="h-5 w-5"/>
<span>Import Database</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-gray-500">
Restore the database from a <span className="font-medium text-gray-700">.sql</span> or <span className="font-medium text-gray-700">.zip</span> backup file.
This will overwrite all existing data.
</p>
<div className="flex items-center gap-3">
<input ref={fileInputRef} type="file" accept=".sql,.zip" onChange={handleFileChange} className="block text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border file:border-gray-300 file:text-sm file:bg-white file:text-gray-700 hover:file:bg-gray-50 cursor-pointer"/>
</div>
{selectedFile && (<p className="text-sm text-gray-500">
Selected: <span className="font-medium text-gray-800">{selectedFile.name}</span>{" "}
({(selectedFile.size / 1024 / 1024).toFixed(1)} MB)
</p>)}
<Button onClick={handleImportClick} disabled={!selectedFile || restoreMutation.isPending} variant="destructive" className="flex items-center space-x-2">
<Upload className="h-4 w-4"/>
<span>{restoreMutation.isPending ? "Restoring..." : "Import & Restore"}</span>
</Button>
</CardContent>
</Card>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore database?</AlertDialogTitle>
<AlertDialogDescription>
This will overwrite <strong>all existing data</strong> with the contents of{" "}
<strong>{selectedFile?.name}</strong>. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} className="bg-red-600 hover:bg-red-700">
Yes, restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>);
}

View File

@@ -0,0 +1,516 @@
import { useState, useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog";
import { Copy, Eye, EyeOff, RefreshCw, Network, RotateCcw, HardDrive } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => {
const label = h === 0
? "12:00 AM (midnight)"
: h < 12
? `${h}:00 AM`
: h === 12
? "12:00 PM (noon)"
: `${h - 12}:00 PM`;
return { value: h, label };
});
export function NetworkBackupManager() {
return (<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Network className="h-5 w-5"/>
Network Backup
</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="rclone">
<TabsList className="mb-4">
<TabsTrigger value="rclone">
<HardDrive className="h-4 w-4 mr-1"/>
Rclone
</TabsTrigger>
<TabsTrigger value="apikey">
<Network className="h-4 w-4 mr-1"/>
API Key
</TabsTrigger>
</TabsList>
<TabsContent value="rclone">
<RcloneBackupSection />
</TabsContent>
<TabsContent value="apikey">
<ApiKeyBackupSection />
</TabsContent>
</Tabs>
</CardContent>
</Card>);
}
// ============================================================
// Tab 1: Rclone
// ============================================================
function RcloneBackupSection() {
const { toast } = useToast();
// Server (source) state
const [serverEnabled, setServerEnabled] = useState(false);
const [serverPort, setServerPort] = useState(8080);
// Receiver state
const [receiverEnabled, setReceiverEnabled] = useState(false);
const [receiverSyncHour, setReceiverSyncHour] = useState(21);
const [sourceIp, setSourceIp] = useState("");
const [sourcePort, setSourcePort] = useState(8080);
// Auto-import state
const [autoImportEnabled, setAutoImportEnabled] = useState(false);
const [autoImportHour, setAutoImportHour] = useState(22);
const [formLoaded, setFormLoaded] = useState(false);
const { data: rcloneConfig } = useQuery({
queryKey: ["/db/rclone-config"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/rclone-config");
return res.json();
},
});
const { data: rcloneStatus } = useQuery({
queryKey: ["/db/rclone-status"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/rclone-status");
return res.json();
},
refetchInterval: 5000,
});
useEffect(() => {
if (rcloneConfig && !formLoaded) {
setServerEnabled(rcloneConfig.serverEnabled ?? false);
setServerPort(rcloneConfig.serverPort ?? 8080);
setReceiverEnabled(rcloneConfig.receiverEnabled ?? false);
setReceiverSyncHour(rcloneConfig.receiverSyncHour ?? 21);
setSourceIp(rcloneConfig.sourceIp ?? "");
setSourcePort(rcloneConfig.sourcePort ?? 8080);
setAutoImportEnabled(rcloneConfig.autoImportEnabled ?? false);
setAutoImportHour(rcloneConfig.autoImportHour ?? 22);
setFormLoaded(true);
}
}, [rcloneConfig, formLoaded]);
const saveMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("PUT", "/api/database-management/rclone-config", {
serverEnabled,
serverPort,
receiverEnabled,
receiverSyncHour,
sourceIp,
sourcePort,
autoImportEnabled,
autoImportHour,
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] });
toast({ title: "Rclone settings saved" });
},
onError: () => {
toast({ title: "Error", description: "Failed to save rclone settings.", variant: "destructive" });
},
});
const pullNowMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/rclone-pull-now");
if (!res.ok) {
const body = await res.json();
throw new Error(body.details || body.error || "Pull failed");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] });
toast({ title: "Rclone pull complete", description: "Backup files copied from source PC." });
},
onError: (err) => {
queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] });
toast({ title: "Rclone pull failed", description: err.message, variant: "destructive" });
},
});
const importNowMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/auto-import-now");
if (!res.ok) {
const body = await res.json();
throw new Error(body.details || body.error || "Import failed");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] });
toast({ title: "Import complete", description: "Latest backup restored to database." });
},
onError: (err) => {
queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] });
toast({ title: "Import failed", description: err.message, variant: "destructive" });
},
});
const serverRunning = rcloneStatus?.serverRunning ?? false;
return (<div className="space-y-6">
{/* ── Source PC: Serve backups via WebDAV ── */}
<div className="space-y-3 border rounded-lg p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-800">Source PC Serve Backups</p>
<div className={`flex items-center gap-1.5 text-xs ${serverRunning ? "text-green-600" : "text-gray-400"}`}>
<div className={`w-2 h-2 rounded-full ${serverRunning ? "bg-green-500" : "bg-gray-300"}`}/>
{serverRunning ? "Running" : "Stopped"}
</div>
</div>
<p className="text-xs text-gray-500">
Enable this on the source PC to serve the <code>backups/</code> folder via WebDAV.
The backup PC will connect to this machine to pull files.
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch id="rclone-server-toggle" checked={serverEnabled} onCheckedChange={setServerEnabled}/>
<label htmlFor="rclone-server-toggle" className="text-sm font-medium text-gray-700 cursor-pointer select-none">
Enable WebDAV server
</label>
</div>
</div>
<div className="space-y-1 max-w-xs">
<label className="text-xs font-medium text-gray-600">Port</label>
<Input type="number" placeholder="8080" value={serverPort} onChange={(e) => setServerPort(Number(e.target.value))}/>
</div>
</div>
{/* ── Receiver PC: Pull backups from source ── */}
<div className="space-y-3 border rounded-lg p-4">
<p className="text-sm font-semibold text-gray-800">Receiver PC Pull Backups</p>
<p className="text-xs text-gray-500">
Enable this on the backup PC to pull backup files from the source PC's WebDAV server
at a scheduled time each day.
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch id="rclone-receiver-toggle" checked={receiverEnabled} onCheckedChange={setReceiverEnabled}/>
<label htmlFor="rclone-receiver-toggle" className="text-sm font-medium text-gray-700 cursor-pointer select-none">
Enable daily pull
</label>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">at</label>
<select className="border rounded px-2 py-1 text-sm text-gray-700 bg-white" value={receiverSyncHour} onChange={(e) => setReceiverSyncHour(Number(e.target.value))}>
{HOUR_OPTIONS.map((o) => (<option key={o.value} value={o.value}>
{o.label}
</option>))}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">Source PC IP</label>
<Input placeholder="192.168.0.94" value={sourceIp} onChange={(e) => setSourceIp(e.target.value)}/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-600">Source PC Port</label>
<Input type="number" placeholder="8080" value={sourcePort} onChange={(e) => setSourcePort(Number(e.target.value))}/>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => pullNowMutation.mutate()} disabled={pullNowMutation.isPending || !sourceIp}>
<RotateCcw className="h-4 w-4 mr-1"/>
{pullNowMutation.isPending ? "Pulling..." : "Pull Now"}
</Button>
</div>
<RcloneSyncStatus />
</div>
{/* ── Auto-Import: restore latest backup to database ── */}
<div className="space-y-3 border rounded-lg p-4">
<p className="text-sm font-semibold text-gray-800">Auto-Import Database</p>
<p className="text-xs text-gray-500">
Automatically restore the latest backup file from the <code>backups/</code> folder
into this PC's database after rclone finishes pulling.
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch id="auto-import-toggle" checked={autoImportEnabled} onCheckedChange={setAutoImportEnabled}/>
<label htmlFor="auto-import-toggle" className="text-sm font-medium text-gray-700 cursor-pointer select-none">
Enable auto-import
</label>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">at</label>
<select className="border rounded px-2 py-1 text-sm text-gray-700 bg-white" value={autoImportHour} onChange={(e) => setAutoImportHour(Number(e.target.value))}>
{HOUR_OPTIONS.map((o) => (<option key={o.value} value={o.value}>
{o.label}
</option>))}
</select>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => importNowMutation.mutate()} disabled={importNowMutation.isPending}>
<RotateCcw className="h-4 w-4 mr-1"/>
{importNowMutation.isPending ? "Importing..." : "Import Now"}
</Button>
</div>
<AutoImportStatus />
</div>
{/* Save all settings */}
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? "Saving..." : "Save Rclone Settings"}
</Button>
</div>);
}
function RcloneSyncStatus() {
const { data } = useQuery({
queryKey: ["/db/rclone-config"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/rclone-config");
return res.json();
},
});
if (!data?.lastSyncAt)
return null;
const date = new Date(data.lastSyncAt).toLocaleString();
const ok = data.lastSyncStatus === "success";
return (<div className={`text-xs rounded p-2 ${ok ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{ok ? "Last pull: " : "Last pull failed: "}{date}
{!ok && data.lastSyncError && (<span className="block mt-0.5 text-red-500">{data.lastSyncError}</span>)}
</div>);
}
function AutoImportStatus() {
const { data } = useQuery({
queryKey: ["/db/rclone-config"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/rclone-config");
return res.json();
},
});
if (!data?.lastImportAt)
return null;
const date = new Date(data.lastImportAt).toLocaleString();
const ok = data.lastImportStatus === "success";
return (<div className={`text-xs rounded p-2 ${ok ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{ok ? "Last import: " : "Last import failed: "}{date}
{!ok && data.lastImportError && (<span className="block mt-0.5 text-red-500">{data.lastImportError}</span>)}
</div>);
}
// ============================================================
// Tab 2: API Key (existing behavior)
// ============================================================
function ApiKeyBackupSection() {
const { toast } = useToast();
const [showKey, setShowKey] = useState(false);
const [showReceiverKey, setShowReceiverKey] = useState(false);
const [confirmRegenOpen, setConfirmRegenOpen] = useState(false);
const [enabled, setEnabled] = useState(false);
const [syncHour, setSyncHour] = useState(0);
const [sourceUrl, setSourceUrl] = useState("");
const [receiverApiKey, setReceiverApiKey] = useState("");
const [formLoaded, setFormLoaded] = useState(false);
const { data: keyData } = useQuery({
queryKey: ["/db/network-backup-key"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/network-backup-key");
return res.json();
},
});
const { data: syncConfig } = useQuery({
queryKey: ["/db/network-sync-config"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/network-sync-config");
return res.json();
},
});
useEffect(() => {
if (syncConfig && !formLoaded) {
setEnabled(syncConfig.enabled ?? false);
setSyncHour(syncConfig.syncHour ?? 0);
setSourceUrl(syncConfig.sourceUrl ?? "");
setReceiverApiKey(syncConfig.apiKey ?? "");
setFormLoaded(true);
}
}, [syncConfig, formLoaded]);
const regenMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/network-backup-key/regenerate");
return res.json();
},
onSuccess: (data) => {
queryClient.setQueryData(["/db/network-backup-key"], data);
setConfirmRegenOpen(false);
toast({ title: "API key regenerated", description: "Update the key on your backup PC." });
},
onError: () => {
toast({ title: "Error", description: "Failed to regenerate key.", variant: "destructive" });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("PUT", "/api/database-management/network-sync-config", {
enabled,
syncHour,
sourceUrl: sourceUrl.trim(),
apiKey: receiverApiKey.trim(),
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/db/network-sync-config"] });
toast({ title: "Network sync settings saved" });
},
onError: () => {
toast({ title: "Error", description: "Failed to save settings.", variant: "destructive" });
},
});
const syncNowMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/database-management/network-sync-now");
if (!res.ok) {
const body = await res.json();
throw new Error(body.details || body.error || "Sync failed");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/db/network-sync-config"] });
toast({ title: "Sync complete", description: "Database synced from source PC." });
},
onError: (err) => {
queryClient.invalidateQueries({ queryKey: ["/db/network-sync-config"] });
toast({ title: "Sync failed", description: err.message, variant: "destructive" });
},
});
const displayKey = keyData?.apiKey ?? "";
const maskedKey = displayKey ? "••••••••-••••-••••-••••-" + displayKey.slice(-12) : "—";
return (<div className="space-y-6">
{/* Source role */}
<div className="space-y-3">
<p className="text-sm font-semibold text-gray-800">This Machine's Backup Key</p>
<p className="text-xs text-gray-500">
Share this key with the backup PC so it can pull a copy of this machine's
database. The key survives database restores (stored in a local file).
</p>
<div className="flex items-center gap-2">
<Input readOnly value={showKey ? displayKey : maskedKey} className="font-mono text-sm"/>
<Button variant="outline" size="icon" title={showKey ? "Hide key" : "Show key"} onClick={() => setShowKey((v) => !v)}>
{showKey ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
</Button>
<Button variant="outline" size="icon" title="Copy to clipboard" onClick={() => {
navigator.clipboard.writeText(displayKey);
toast({ title: "Copied to clipboard" });
}} disabled={!displayKey}>
<Copy className="h-4 w-4"/>
</Button>
<Button variant="outline" size="icon" title="Regenerate key" onClick={() => setConfirmRegenOpen(true)} disabled={regenMutation.isPending}>
<RefreshCw className="h-4 w-4"/>
</Button>
</div>
</div>
<div className="border-t"/>
{/* Receiver role */}
<div className="space-y-4">
<p className="text-sm font-semibold text-gray-800">Sync from Another PC</p>
<p className="text-xs text-gray-500">
Configure this machine to pull a fresh copy of the database and all uploaded
files (patient photos, cloud storage, documents) from another PC at a scheduled
time each day. Enter the source PC's URL (e.g. http://192.168.0.94 — no port number) and the Backup Key shown in the source PC's Network Backup section.
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch id="network-sync-toggle" checked={enabled} onCheckedChange={setEnabled}/>
<label htmlFor="network-sync-toggle" className="text-sm font-medium text-gray-700 cursor-pointer select-none">
Enable daily sync
</label>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">at</label>
<select className="border rounded px-2 py-1 text-sm text-gray-700 bg-white" value={syncHour} onChange={(e) => setSyncHour(Number(e.target.value))}>
{HOUR_OPTIONS.map((o) => (<option key={o.value} value={o.value}>
{o.label}
</option>))}
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">Source PC URL</label>
<Input placeholder="http://192.168.0.94" value={sourceUrl} onChange={(e) => setSourceUrl(e.target.value)}/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-gray-600">Source PC API Key</label>
<div className="flex items-center gap-2">
<Input type={showReceiverKey ? "text" : "password"} placeholder="Paste the key from the source PC" value={receiverApiKey} onChange={(e) => setReceiverApiKey(e.target.value)}/>
<Button variant="outline" size="icon" title={showReceiverKey ? "Hide key" : "Show key"} onClick={() => setShowReceiverKey((v) => !v)} type="button">
{showReceiverKey ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
</Button>
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? "Saving..." : "Save Settings"}
</Button>
<Button variant="outline" onClick={() => syncNowMutation.mutate()} disabled={syncNowMutation.isPending || !sourceUrl || !receiverApiKey} title="Pull and restore now — replaces this machine's database and uploads folder">
<RotateCcw className="h-4 w-4 mr-1"/>
{syncNowMutation.isPending ? "Syncing..." : "Sync Now"}
</Button>
</div>
<ApiKeySyncStatus />
</div>
{/* Confirm regenerate dialog */}
<AlertDialog open={confirmRegenOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate backup key?</AlertDialogTitle>
<AlertDialogDescription>
The old key will stop working immediately. You will need to update the API key
on any backup PC that is currently configured to sync from this machine.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmRegenOpen(false)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => regenMutation.mutate()} disabled={regenMutation.isPending}>
Regenerate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>);
}
function ApiKeySyncStatus() {
const { data } = useQuery({
queryKey: ["/db/network-sync-config"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/network-sync-config");
return res.json();
},
});
if (!data?.lastSyncAt)
return null;
const date = new Date(data.lastSyncAt).toLocaleString();
const ok = data.lastSyncStatus === "success";
return (<div className={`text-xs rounded p-2 ${ok ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{ok ? "Last sync: " : "Last sync failed: "}{date}
{!ok && data.lastSyncError && (<span className="block mt-0.5 text-red-500">{data.lastSyncError}</span>)}
</div>);
}

View File

@@ -0,0 +1,221 @@
import React, { useEffect, useState } from "react";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { Maximize2, Minimize2, Download, X } from "lucide-react";
import { viewDocument } from "@/lib/api/documents";
export default function DocumentsFilePreviewModal({ fileId, isOpen, onClose, initialFileName, isPatientDocument = false, directImageUrl, }) {
const [loading, setLoading] = useState(false);
const [mime, setMime] = useState(null);
const [fileName, setFileName] = useState(initialFileName ?? null);
const [blobUrl, setBlobUrl] = useState(null);
const [error, setError] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!isOpen || !fileId)
return;
let cancelled = false;
let createdUrl = null;
async function load() {
setLoading(true);
setError(null);
setMime(null);
setFileName(initialFileName ?? null);
setBlobUrl(null);
try {
let res;
if (directImageUrl) {
// Use direct image URL without API call
setBlobUrl(directImageUrl);
// Try to determine MIME type from file extension
const extension = directImageUrl.split('.').pop()?.toLowerCase();
if (extension) {
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension)) {
setMime(`image/${extension === 'jpg' ? 'jpeg' : extension}`);
}
else if (extension === 'pdf') {
setMime('application/pdf');
}
}
setLoading(false);
return;
}
else if (isPatientDocument && fileId) {
// For patient documents, use the viewDocument API to get the URL
const documentUrl = viewDocument(fileId);
res = await fetch(documentUrl);
}
else {
// For PDF files, use the existing endpoint
res = await apiRequest("GET", `/api/documents/pdf-files/${fileId}`);
}
if (!res.ok) {
// try to parse error message from JSON body
let msg = `Preview request failed (${res.status})`;
try {
const j = await res.json();
msg = j?.message ?? msg;
}
catch { }
throw new Error(msg);
}
// try to infer MIME from headers; fallback to application/pdf
const contentType = res.headers.get("content-type") ?? "application/pdf";
setMime(contentType);
// If server provided filename in headers (Content-Disposition), we could parse it here.
// Use initialFileName if provided, otherwise keep unset until download.
if (!fileName && initialFileName)
setFileName(initialFileName);
const arrayBuffer = await res.arrayBuffer();
if (cancelled)
return;
const blob = new Blob([arrayBuffer], { type: contentType });
createdUrl = URL.createObjectURL(blob);
setBlobUrl(createdUrl);
}
catch (err) {
if (!cancelled)
setError(err?.message ?? String(err));
}
finally {
if (!cancelled)
setLoading(false);
}
}
load();
return () => {
cancelled = true;
if (createdUrl)
URL.revokeObjectURL(createdUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, fileId]);
useEffect(() => {
function onKey(e) {
if (e.key === "Escape")
onClose();
}
if (isOpen) {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}
}, [isOpen, onClose]);
if (!isOpen)
return null;
async function handleDownload() {
if (!fileId)
return;
try {
let downloadUrl;
if (directImageUrl) {
// Use the direct image URL and fetch as blob to force download
const response = await fetch(directImageUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = window.document.createElement("a");
link.href = url;
link.download = fileName ?? `file-${fileId}`;
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
window.URL.revokeObjectURL(url);
return;
}
else {
// For PDF files, use the existing endpoint
const res = await apiRequest("GET", `/api/documents/pdf-files/${fileId}`);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j?.message || `Download failed (${res.status})`);
}
const arrayBuffer = await res.arrayBuffer();
const blob = new Blob([arrayBuffer], {
type: mime ?? "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName ?? `file-${fileId}`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
return;
}
// For download API URLs, create download link
const a = document.createElement("a");
a.href = downloadUrl;
a.download = fileName ?? `file-${fileId}`;
document.body.appendChild(a);
a.click();
a.remove();
}
catch (err) {
toast({
title: "Download failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
const containerBase = "bg-white rounded-md p-3 flex flex-col overflow-hidden shadow-xl";
const sizeClass = isFullscreen
? "w-[95vw] h-[95vh]"
: "w-[min(1200px,95vw)] h-[85vh]";
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
<div className={`${containerBase} ${sizeClass} max-w-full max-h-full`}>
<div className="flex items-start justify-between gap-3 pb-2 border-b">
<div className="min-w-0">
<h3 className="text-lg font-semibold truncate">
{fileName ?? `File #${fileId}`}
</h3>
<div className="text-sm text-gray-500 truncate">{mime ?? ""}</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setIsFullscreen((s) => !s)} title={isFullscreen ? "Exit fullscreen" : "Fullscreen"} className="p-2 rounded hover:bg-gray-100">
{isFullscreen ? (<Minimize2 className="w-4 h-4"/>) : (<Maximize2 className="w-4 h-4"/>)}
</button>
<button onClick={handleDownload} title="Download" className="p-2 rounded hover:bg-gray-100">
<Download className="w-4 h-4"/>
</button>
<button onClick={onClose} title="Close" className="p-2 rounded hover:bg-gray-100">
<X className="w-4 h-4"/>
</button>
</div>
</div>
<div className="flex-1 overflow-auto mt-3">
{loading && (<div className="w-full h-full flex items-center justify-center">
Loading preview
</div>)}
{error && <div className="text-red-600">{error}</div>}
{!loading && !error && blobUrl && mime?.startsWith("image/") && (<div className="flex items-center justify-center w-full h-full">
<img src={blobUrl} alt={fileName ?? ""} className="max-w-full max-h-full object-contain"/>
</div>)}
{!loading &&
!error &&
blobUrl &&
(mime === "application/pdf" || mime?.endsWith("/pdf")) && (<div className="w-full h-full">
<iframe src={blobUrl} title={fileName ?? `PDF ${fileId}`} className="w-full h-full border-0"/>
</div>)}
{!loading &&
!error &&
blobUrl &&
!mime?.startsWith("image/") &&
!mime?.includes("pdf") && (<div className="p-4">
<p>Preview not available for this file type.</p>
<p className="mt-2">
<a href={blobUrl} target="_blank" rel="noopener noreferrer" className="underline">
Open raw
</a>
</p>
</div>)}
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,195 @@
import React, { useState, useRef, useCallback } from "react";
import { Upload, File, X, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
export function FileUploadZone({ onFileUpload, isUploading, acceptedFileTypes = "application/pdf", maxFileSizeMB = 10, // default 10mb
maxFileSizeByType, }) {
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [uploadedFile, setUploadedFile] = useState(null);
const fileInputRef = useRef(null);
// helpers
const mbToBytes = (mb) => Math.round(mb * 1024 * 1024);
const humanSize = (bytes) => {
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
const parsedAccept = acceptedFileTypes
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const allowedBytesForMime = (mime) => {
if (!mime)
return mbToBytes(maxFileSizeMB);
if (maxFileSizeByType && maxFileSizeByType[mime] != null) {
return mbToBytes(maxFileSizeByType[mime]);
}
const parts = mime.split("/");
if (parts.length === 2) {
const wildcard = `${parts[0]}/*`;
if (maxFileSizeByType && maxFileSizeByType[wildcard] != null) {
return mbToBytes(maxFileSizeByType[wildcard]);
}
}
return mbToBytes(maxFileSizeMB);
};
const isMimeAllowed = (fileType) => {
if (!fileType)
return false;
const ft = fileType.toLowerCase();
for (const a of parsedAccept) {
if (a === ft)
return true;
if (a === "*/*")
return true;
if (a.endsWith("/*")) {
const major = a.split("/")[0];
if (ft.startsWith(`${major}/`))
return true;
}
}
return false;
};
const validateFile = (file) => {
// <<< CHANGED: use isMimeAllowed instead of strict include
if (!isMimeAllowed(file.type)) {
toast({
title: "Invalid file type",
description: "Please upload a supported file type.",
variant: "destructive",
});
return false;
}
const allowedBytes = allowedBytesForMime(file.type);
if (file.size > allowedBytes) {
toast({
title: "File too large",
description: `${file.name} is ${humanSize(file.size)} — max for this type is ${humanSize(allowedBytes)}`,
variant: "destructive",
});
return false;
}
return true;
};
const handleDragEnter = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) {
setIsDragging(true);
}
}, [isDragging]);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
if (validateFile(file)) {
setUploadedFile(file);
onFileUpload(file);
}
}
}, [onFileUpload, acceptedFileTypes, toast]);
const handleFileSelect = useCallback((e) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
if (validateFile(file)) {
setUploadedFile(file);
onFileUpload(file);
}
}
}, [onFileUpload, acceptedFileTypes, toast]);
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleRemoveFile = () => {
setUploadedFile(null);
};
const typeBadges = parsedAccept.map((t) => {
const display = t === "image/*"
? "Images"
: t.includes("/")
? t.split("/")[1].toUpperCase()
: t.toUpperCase();
const mb = (maxFileSizeByType &&
(maxFileSizeByType[t] ?? maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return { key: t, label: `${display}${mb} MB`, mb };
});
return (<div className="w-full">
<input type="file" ref={fileInputRef} className="hidden" onChange={handleFileSelect} accept={acceptedFileTypes}/>
<div className={cn("border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center text-center transition-colors", isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25", uploadedFile ? "bg-success/5" : "hover:bg-muted/40", isUploading && "opacity-50 cursor-not-allowed")} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} onClick={!uploadedFile && !isUploading ? handleBrowseClick : undefined} style={{ minHeight: "200px" }}>
{isUploading ? (<div className="flex flex-col items-center gap-4">
<div className="animate-spin">
<Upload className="h-10 w-10 text-primary"/>
</div>
<p className="text-sm font-medium">Uploading file...</p>
</div>) : uploadedFile ? (<div className="flex flex-col items-center gap-4">
<div className="relative">
<File className="h-12 w-12 text-primary"/>
<button className="absolute -top-2 -right-2 bg-background rounded-full p-1 shadow-sm border" onClick={(e) => {
e.stopPropagation();
handleRemoveFile();
}}>
<X className="h-4 w-4 text-muted-foreground"/>
</button>
</div>
<div>
<p className="font-medium text-primary">{uploadedFile.name}</p>
<p className="text-sm text-muted-foreground mt-1">
{humanSize(uploadedFile.size)} allowed{" "}
{humanSize(allowedBytesForMime(uploadedFile.type))}
{" • "}
{uploadedFile.type || "unknown"}
</p>
</div>
<p className="text-sm text-muted-foreground">
File ready to process
</p>
</div>) : (<div className="flex flex-col items-center gap-4">
<FilePlus className="h-12 w-12 text-primary/70"/>
<div>
<p className="font-medium text-primary">
Drag and drop a PDF file here
</p>
<div className="flex flex-wrap gap-2 justify-center mt-2">
{typeBadges.map((b) => (<span key={b.key} className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700" title={b.label}>
{b.label}
</span>))}
</div>
<p className="text-sm text-muted-foreground mt-1">
Or click to browse files
</p>
</div>
<Button type="button" variant="secondary" onClick={(e) => {
e.stopPropagation();
handleBrowseClick();
}}>
Browse files
</Button>
<p className="text-xs text-muted-foreground">
Accepts {acceptedFileTypes} max {maxFileSizeMB} MB (default)
</p>
</div>)}
</div>
</div>);
}

View File

@@ -0,0 +1,326 @@
import React, { useState, useRef, useCallback, forwardRef, useImperativeHandle, } from "react";
import { Upload, X, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
export const MultipleFileUploadZone = forwardRef(({ onFilesChange, isUploading = false, acceptedFileTypes = "application/pdf,image/jpeg,image/jpg,image/png,image/webp", maxFiles = 10, maxFileSizeMB = 10, // default fallback per-file size (MB)
maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 }
}, ref) => {
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState([]);
const fileInputRef = useRef(null);
const parsedAccept = acceptedFileTypes
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
// helper: convert MB -> bytes
const mbToBytes = (mb) => Math.round(mb * 1024 * 1024);
// human readable size
const humanSize = (bytes) => {
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
// Determine allowed bytes for a given file mime:
// Priority: exact mime -> wildcard major/* -> default maxFileSizeMB
const allowedBytesForMime = (mime) => {
if (!mime)
return mbToBytes(maxFileSizeMB);
// exact match
if (maxFileSizeByType && maxFileSizeByType[mime] != null) {
return mbToBytes(maxFileSizeByType[mime]);
}
// wildcard match: image/*, audio/* etc.
const parts = mime.split("/");
if (parts.length === 2) {
const wildcard = `${parts[0]}/*`;
if (maxFileSizeByType && maxFileSizeByType[wildcard] != null) {
return mbToBytes(maxFileSizeByType[wildcard]);
}
}
// fallback default
return mbToBytes(maxFileSizeMB);
};
const isMimeAllowed = (fileType) => {
const ft = (fileType || "").toLowerCase();
for (const a of parsedAccept) {
if (a === ft)
return true;
if (a === "*/*")
return true;
if (a.endsWith("/*")) {
const major = a.split("/")[0];
if (ft.startsWith(`${major}/`))
return true;
}
}
return false;
};
// Validation uses allowedBytesForMime
const validateFile = (file) => {
if (!isMimeAllowed(file.type)) {
toast({
title: "Invalid file type",
description: "Only the allowed file types are permitted.",
variant: "destructive",
});
return false;
}
const allowed = allowedBytesForMime(file.type);
if (file.size > allowed) {
toast({
title: "File too large",
description: `${file.name} is ${humanSize(file.size)} — max allowed for this type is ${humanSize(allowed)}.`,
variant: "destructive",
});
return false;
}
return true;
};
// ----------------- friendly label helper -----------------
// Convert acceptedFileTypes MIME list into human-friendly labels
const buildFriendlyTypes = (accept) => {
const types = accept
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
// track whether generic image/* is present
const hasImageWildcard = types.includes("image/*");
const names = new Set();
for (const t of types) {
if (t === "image/*") {
names.add("images");
continue;
}
if (t.includes("pdf")) {
names.add("PDF");
continue;
}
if (t.includes("jpeg") || t.includes("jpg")) {
names.add("JPG");
continue;
}
if (t.includes("png")) {
names.add("PNG");
continue;
}
if (t.includes("webp")) {
names.add("WEBP");
continue;
}
if (t.includes("tiff") || t.includes("tif")) {
names.add("TIFF");
continue;
}
if (t.includes("bmp")) {
names.add("BMP");
continue;
}
// fallback: attempt to extract subtype (safe)
if (t.includes("/")) {
const parts = t.split("/");
const subtype = parts[1]; // may be undefined if malformed
if (subtype) {
names.add(subtype.toUpperCase());
}
}
else {
names.add(t.toUpperCase());
}
}
return {
hasImageWildcard,
names: Array.from(names),
};
};
const friendly = buildFriendlyTypes(acceptedFileTypes);
// Build main title text
const uploadTitle = (() => {
const { hasImageWildcard, names } = friendly;
// if only "images"
if (hasImageWildcard && names.length === 1)
return "Drag and drop image files here";
// if includes images plus specific others (e.g., image/* + pdf)
if (hasImageWildcard && names.length > 1) {
const others = names.filter((n) => n !== "images");
return `Drag and drop image files (${others.join(", ")}) here`;
}
// no wildcard images: list the types
if (names.length === 0)
return "Drag and drop files here";
if (names.length === 1)
return `Drag and drop ${names[0]} files here`;
// multiple: join
return `Drag and drop ${names.join(", ")} files here`;
})();
// Build footer allowed types text (small)
const allowedHuman = (() => {
const { hasImageWildcard, names } = friendly;
if (hasImageWildcard) {
// show images + any explicit types (excluding 'images')
const extras = names.filter((n) => n !== "images");
return extras.length
? `Images (${extras.join(", ")}), ${maxFiles} max`
: `Images, ${maxFiles} max`;
}
if (names.length === 0)
return `Files, Max ${maxFiles}`;
return `${names.join(", ")}, Max ${maxFiles}`;
})();
// ----------------- end helper -----------------
const notify = useCallback((files) => {
onFilesChange?.(files);
}, [onFilesChange]);
const handleFiles = (files) => {
if (!files)
return;
const newFiles = Array.from(files).filter(validateFile);
const totalFiles = uploadedFiles.length + newFiles.length;
if (totalFiles > maxFiles) {
toast({
title: "Too Many Files",
description: `You can only upload up to ${maxFiles} files.`,
variant: "destructive",
});
return;
}
const updatedFiles = [...uploadedFiles, ...newFiles];
setUploadedFiles(updatedFiles);
notify(updatedFiles);
};
const handleDragEnter = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) {
setIsDragging(true);
}
}, [isDragging]);
const handleDrop = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
}, [uploadedFiles]);
const handleFileSelect = useCallback((e) => {
handleFiles(e.target.files);
}, [uploadedFiles]);
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleRemoveFile = (index) => {
const newFiles = [...uploadedFiles];
newFiles.splice(index, 1);
setUploadedFiles(newFiles);
notify(newFiles);
};
// expose imperative handle to parent
useImperativeHandle(ref, () => ({
getFiles: () => uploadedFiles.slice(),
reset: () => {
setUploadedFiles([]);
notify([]);
if (fileInputRef.current)
fileInputRef.current.value = "";
},
removeFile: (index) => {
handleRemoveFile(index);
},
}), [uploadedFiles, notify]);
return (<div className="w-full">
<input type="file" ref={fileInputRef} className="hidden" onChange={handleFileSelect} accept={acceptedFileTypes} multiple/>
<div className={cn("border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center text-center transition-colors", isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25", isUploading && "opacity-50 cursor-not-allowed")} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} onClick={!isUploading ? handleBrowseClick : undefined} style={{ minHeight: "200px" }}>
{isUploading ? (<div className="flex flex-col items-center gap-4">
<div className="animate-spin">
<Upload className="h-10 w-10 text-primary"/>
</div>
<p className="text-sm font-medium">Uploading files...</p>
</div>) : uploadedFiles.length > 0 ? (<div className="flex flex-col items-center gap-4 w-full">
<p className="font-medium text-primary">
{uploadedFiles.length} file(s) uploaded
</p>
<ul className="w-full text-left space-y-2">
{uploadedFiles.map((file, index) => (<li key={index} className="flex justify-between items-center border-b pb-1">
<span className="text-sm">{file.name}</span>
<span className="text-xs text-muted-foreground">
{humanSize(file.size)} {file.type || "unknown"}
</span>
<button className="ml-2 p-1 text-muted-foreground hover:text-red-500" onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}>
<X className="h-4 w-4"/>
</button>
</li>))}
</ul>
{/* prominent per-type size badges */}
<div className="flex flex-wrap gap-2 justify-center mt-2">
{parsedAccept.map((t) => {
const display = t === "image/*"
? "Images"
: t.includes("/")
? t.split("/")[1].toUpperCase()
: t.toUpperCase();
const mb = (maxFileSizeByType &&
(maxFileSizeByType[t] ??
maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return (<span key={t} className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700" title={`${display} — max ${mb} MB`}>
{display} {mb} MB
</span>);
})}
</div>
</div>) : (<div className="flex flex-col items-center gap-4">
<FilePlus className="h-12 w-12 text-primary/70"/>
<div>
<p className="font-medium text-primary">{uploadTitle}</p>
{/* show same badges above file list so user sees limits after selecting */}
<div className="flex flex-wrap gap-2 justify-center mt-2">
{parsedAccept.map((t) => {
const display = t === "image/*"
? "Images"
: t.includes("/")
? t.split("/")[1].toUpperCase()
: t.toUpperCase();
const mb = (maxFileSizeByType &&
(maxFileSizeByType[t] ??
maxFileSizeByType[`${t.split("/")[0]}/*`])) ??
maxFileSizeMB;
return (<span key={t + "-list"} className="text-xs px-2 py-1 rounded-full border bg-gray-50 text-gray-700" title={`${display} — max ${mb} MB`}>
{display} {mb} MB
</span>);
})}
</div>
<p className="text-sm text-muted-foreground mt-1">
Or click to browse files
</p>
</div>
<Button type="button" variant="default" onClick={(e) => {
e.stopPropagation();
handleBrowseClick();
}}>
Browse files
</Button>
</div>)}
</div>
</div>);
});
MultipleFileUploadZone.displayName = "MultipleFileUploadZone";

View File

@@ -0,0 +1,212 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function BcbsMaOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP BCBS MA</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
Enter the last 6 digits of the one-time verification code sent by the BCBS MA Provider
Central portal to your registered email. The email shows a code like{" "}
<span className="font-mono font-medium">XXXX-XXXXXX</span> enter only the last 6 digits.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bcbs-ma-otp">Last 6 digits of OTP</Label>
<Input id="bcbs-ma-otp" placeholder="e.g. 482913" value={otp} onChange={(e) => setOtp(e.target.value)} maxLength={6} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit OTP")}
</Button>
</div>
</form>
</div>
</div>);
}
export function BcbsMaEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "BCBS_MA",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Starting BCBS MA eligibility check…" }));
const response = await apiRequest("POST", "/api/insurance-status-bcbs-ma/bcbs-ma-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "BCBS MA job queued. Opening browser…" }));
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Browser started. Waiting for OTP…" }));
};
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "OTP required for BCBS MA. Please enter the code." }));
};
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "OTP submitted. Finishing BCBS MA eligibility check…" }));
};
function cleanup() {
socket.off("selenium:bcbs_ma_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: "BCBS MA job timed out." }));
}, 10 * 60 * 1000);
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: data.message ?? "Browser starting…" }));
return;
}
clearTimeout(safetyTimer);
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "BCBS MA eligibility updated and PDF saved." }));
toast({ title: "BCBS MA eligibility complete", description: "Patient status was updated and the eligibility PDF was saved." });
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_bcbs_ma_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "BCBS MA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "BCBS MA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("selenium:bcbs_ma_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
socket.on("job:update", onJobUpdate);
}
catch (err) {
console.error("BcbsMaEligibilityButton error:", err);
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: err?.message || "Failed to start BCBS MA eligibility" }));
toast({ title: "BCBS MA selenium error", description: err?.message || "Failed to start BCBS MA eligibility", variant: "destructive" });
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({ title: "Session not ready", description: "Cannot submit OTP — session ID not yet available.", variant: "destructive" });
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-bcbs-ma/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error)
throw new Error(data.error || "Failed to submit OTP");
setOtpModalOpen(false);
}
catch (err) {
toast({ title: "Failed to submit OTP", description: err?.message || "Error forwarding OTP to selenium agent", variant: "destructive" });
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger || autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
BCBS MA
</>)}
</Button>
<BcbsMaOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle, LoaderCircleIcon } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
export function CCAEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [isStarting, setIsStarting] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "CCA",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting CCA eligibility check…",
}));
const response = await apiRequest("POST", "/api/insurance-status-cca/cca-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "CCA job queued. Waiting for browser session…",
}));
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Running eligibility check…",
}));
};
socket.on("selenium:cca_session_started", onSessionStarted);
function cleanup() {
clearTimeout(safetyTimer);
socket.off("selenium:cca_session_started", onSessionStarted);
socket.off("job:update", onJobUpdate);
}
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "CCA eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "CCA eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_cca_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "CCA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "CCA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
};
socket.on("job:update", onJobUpdate);
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "CCA job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
}
catch (err) {
console.error("CCAEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start CCA eligibility",
}));
toast({
title: "CCA selenium error",
description: err?.message || "Failed to start CCA eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
CCA
</>)}
</Button>);
}

View File

@@ -0,0 +1,327 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function DdmaOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent by the Delta Dental MA portal to complete this
eligibility check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ddma-otp">OTP</Label>
<Input id="ddma-otp" placeholder="Enter OTP code" value={otp} onChange={(e) => setOtp(e.target.value)} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit OTP")}
</Button>
</div>
</form>
</div>
</div>);
}
export function DdmaEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
// ── Socket event handlers ─────────────────────────────────────────────────
const handleDdmaStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "DDMA",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting DDMA eligibility check…",
}));
// 1) POST to backend — returns { status: "queued", jobId }
const response = await apiRequest("POST", "/api/insurance-status-ddma/ddma-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "DDMA job queued. Waiting for browser session to start…",
}));
// 2) Listen for job-lifecycle and DDMA-specific socket events.
// All events come through the shared app socket.
// Handler: Python agent started a browser session → we now have session_id
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Waiting for OTP or result…",
}));
};
// Handler: OTP is required by the DDMA portal
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
// Update sessionId in case it arrives here first
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP required for Delta Dental MA. Please enter the code.",
}));
};
// Handler: OTP accepted by Python agent (optional UX feedback)
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP submitted. Finishing DDMA eligibility check…",
}));
};
// Handler: job completed or failed (from InProcessQueue)
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
// Terminal states
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "DDMA eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "DDMA eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
const filename = data.result?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`;
onPdfReady(Number(pdfId), filename);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "DDMA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "DDMA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
// Attach listeners
socket.on("selenium:ddma_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
socket.on("job:update", onJobUpdate);
// Cleanup helper removes all listeners for this job
function cleanup() {
socket.off("selenium:ddma_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
// Safety timeout — clean up listeners if no terminal event in 6 min
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "DDMA job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
// Patch cleanup to also clear the timer
const originalCleanup = cleanup;
function cleanupWithTimer() {
clearTimeout(safetyTimer);
originalCleanup();
}
// Override the onJobUpdate cleanup reference
socket.off("job:update", onJobUpdate);
socket.on("job:update", (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanupWithTimer();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "DDMA eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "DDMA eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "DDMA eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "DDMA selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
});
}
catch (err) {
console.error("DdmaEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start DDMA eligibility",
}));
toast({
title: "DDMA selenium error",
description: err?.message || "Failed to start DDMA eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
// ── OTP submission ────────────────────────────────────────────────────────
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit OTP — DDMA session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-ddma/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit OTP");
}
setOtpModalOpen(false);
}
catch (err) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit OTP",
description: err?.message || "Error forwarding OTP to selenium agent",
variant: "destructive",
});
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleDdmaStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
// ── Render ────────────────────────────────────────────────────────────────
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleDdmaStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
Delta MA
</>)}
</Button>
<DdmaOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,270 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function DeltaInsOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the one-time password (OTP) sent by the Delta Dental Ins portal to your email.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="deltains-otp">OTP</Label>
<Input id="deltains-otp" placeholder="Enter OTP code" value={otp} onChange={(e) => setOtp(e.target.value)} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit OTP")}
</Button>
</div>
</form>
</div>
</div>);
}
export function DeltaInsEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "DELTAINS",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting Delta Ins eligibility check…",
}));
const response = await apiRequest("POST", "/api/insurance-status-deltains/deltains-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Delta Ins job queued. Waiting for browser session to start…",
}));
// Handler: Python agent started a browser session
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Waiting for OTP or result…",
}));
};
// Handler: OTP required
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP required for Delta Dental Ins. Please enter the code from your email.",
}));
};
// Handler: OTP accepted
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "OTP submitted. Finishing Delta Ins eligibility check…",
}));
};
socket.on("selenium:deltains_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
function cleanup() {
clearTimeout(safetyTimer);
socket.off("selenium:deltains_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "Delta Ins eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "Delta Ins eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_deltains_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "Delta Ins eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "Delta Ins selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("job:update", onJobUpdate);
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "Delta Ins job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
}
catch (err) {
console.error("DeltaInsEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start Delta Ins eligibility",
}));
toast({
title: "Delta Ins selenium error",
description: err?.message || "Failed to start Delta Ins eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit OTP — Delta Ins session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-deltains/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit OTP");
}
setOtpModalOpen(false);
}
catch (err) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit OTP",
description: err?.message || "Error forwarding OTP to selenium agent",
variant: "destructive",
});
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
Deltains
</>)}
</Button>
<DeltaInsOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
function parseFilename(header) {
if (!header)
return null;
const starMatch = header.match(/filename\*\s*=\s*([^;]+)/i);
if (starMatch?.[1]) {
const raw = starMatch[1].trim().replace(/^"(.*)"$/, "$1");
const parts = raw.split("''");
if (parts.length === 2 && parts[1]) {
try {
return decodeURIComponent(parts[1]);
}
catch {
return parts[1];
}
}
try {
return decodeURIComponent(raw);
}
catch {
return raw;
}
}
const quoted = header.match(/filename\s*=\s*"([^"]+)"/i);
if (quoted?.[1])
return quoted[1].trim();
const plain = header.match(/filename\s*=\s*([^;]+)/i);
if (plain?.[1])
return plain[1].trim().replace(/^"(.*)"$/, "$1");
return null;
}
function usePdfBlob(open, pdfId, fallbackFilename) {
const [blobUrl, setBlobUrl] = useState(null);
const [filename, setFilename] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open || !pdfId)
return;
let objectUrl = null;
let aborted = false;
(async () => {
setLoading(true);
setError(null);
try {
const res = await apiRequest("GET", `/api/documents/pdf-files/${pdfId}`);
if (!res?.ok) {
const txt = await res?.text().catch(() => "");
throw new Error(txt || `Failed to fetch PDF: ${res?.status}`);
}
const header = res.headers?.get?.("content-disposition") ?? null;
const finalName = parseFilename(header) ?? fallbackFilename ?? `file_${pdfId}.pdf`;
setFilename(finalName);
const buf = await res.arrayBuffer();
if (aborted)
return;
const blob = new Blob([buf], { type: "application/pdf" });
objectUrl = URL.createObjectURL(blob);
setBlobUrl(objectUrl);
}
catch (e) {
if (e?.name === "AbortError")
return;
setError(e?.message ?? "Failed to fetch PDF");
}
finally {
setLoading(false);
}
})();
return () => {
aborted = true;
if (objectUrl)
URL.revokeObjectURL(objectUrl);
setBlobUrl(null);
setError(null);
setLoading(false);
setFilename(null);
};
}, [open, pdfId, fallbackFilename]);
return { blobUrl, filename, loading, error };
}
function PdfPanel({ config, open }) {
const { blobUrl, filename, loading, error } = usePdfBlob(open, config.pdfId, config.fallbackFilename);
// Auto-download via direct API URL to avoid Chrome Safe Browsing pause
useEffect(() => {
if (!config.autoDownload || !config.pdfId || !filename)
return;
const a = document.createElement("a");
a.href = `/api/documents/pdf-files/${config.pdfId}`;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, [config.autoDownload, config.pdfId, filename]);
const handleDownload = () => {
if (!config.pdfId || !filename)
return;
const a = document.createElement("a");
a.href = `/api/documents/pdf-files/${config.pdfId}`;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
return (<div className="flex flex-col flex-1 min-w-0 border-r last:border-r-0">
<div className="flex items-center justify-between px-3 py-2 border-b bg-gray-50 shrink-0">
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{config.label}
</span>
<span className="text-sm font-medium truncate" title={filename ?? undefined}>
{filename ?? "Loading…"}
</span>
</div>
<Button variant="ghost" size="sm" onClick={handleDownload} disabled={!config.pdfId || !filename} className="shrink-0 ml-2">
Download
</Button>
</div>
<div className="flex-1 overflow-hidden p-2">
{loading && (<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Loading PDF
</div>)}
{error && (<div className="flex items-center justify-center h-full text-sm text-destructive">
Error: {error}
</div>)}
{!config.pdfId && !loading && (<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
No PDF available
</div>)}
{blobUrl && (<iframe title={config.label} src={blobUrl} className="w-full h-full border rounded" style={{ minHeight: 0 }}/>)}
</div>
</div>);
}
export function DualPdfPreviewModal({ open, onClose, panels, title }) {
if (!open)
return null;
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-lg flex flex-col" style={{ width: "92vw", height: "88vh", maxWidth: 1600 }}>
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
<h3 className="text-base font-semibold">
{title ?? "PDF Preview"}
</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
Close
</Button>
</div>
<div className="flex flex-1 min-h-0 divide-x">
{panels.map((panel, i) => (<PdfPanel key={i} config={panel} open={open}/>))}
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,174 @@
// src/components/insurance-status/pdf-preview-modal.tsx
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import { Maximize2, Minimize2 } from "lucide-react";
function parseFilenameFromContentDisposition(header) {
if (!header)
return null;
const filenameStarMatch = header.match(/filename\*\s*=\s*([^;]+)/i);
if (filenameStarMatch && filenameStarMatch[1]) {
let raw = filenameStarMatch[1].trim();
raw = raw.replace(/^"(.*)"$/, "$1");
const parts = raw.split("''");
if (parts.length === 2 && parts[1]) {
try {
return decodeURIComponent(parts[1]);
}
catch {
return parts[1];
}
}
try {
return decodeURIComponent(raw);
}
catch {
return raw;
}
}
const filenameMatchQuoted = header.match(/filename\s*=\s*"([^"]+)"/i);
if (filenameMatchQuoted && filenameMatchQuoted[1]) {
return filenameMatchQuoted[1].trim();
}
const filenameMatch = header.match(/filename\s*=\s*([^;]+)/i);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1].trim().replace(/^"(.*)"$/, "$1");
}
return null;
}
export function PdfPreviewModal({ open, onClose, pdfId, fallbackFilename = null, autoDownload = false, }) {
const [fileBlobUrl, setFileBlobUrl] = useState(null);
const [isImage, setIsImage] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [resolvedFilename, setResolvedFilename] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!open)
return;
let objectUrl = null;
const controller = new AbortController();
let aborted = false;
const fetchPdf = async () => {
if (!pdfId) {
setError("No PDF id provided.");
return;
}
setLoading(true);
setError(null);
setResolvedFilename(null);
try {
const res = await apiRequest("GET", `/api/documents/pdf-files/${pdfId}`);
if (!res) {
throw new Error("No response from server");
}
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to fetch PDF: ${res.status}`);
}
const contentDispHeader = res.headers?.get?.("content-disposition") ??
res.headers?.get?.("Content-Disposition") ??
null;
const parsedFilename = parseFilenameFromContentDisposition(contentDispHeader);
const finalName = parsedFilename ?? fallbackFilename ?? `file_${pdfId}.pdf`;
setResolvedFilename(finalName);
const arrayBuffer = await res.arrayBuffer();
if (aborted)
return;
const lowerName = finalName.toLowerCase();
const isPng = lowerName.endsWith(".png");
const isJpg = lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg");
const mimeType = isPng ? "image/png" : isJpg ? "image/jpeg" : "application/pdf";
const blob = new Blob([arrayBuffer], { type: mimeType });
objectUrl = URL.createObjectURL(blob);
setIsImage(isPng || isJpg);
setFileBlobUrl(objectUrl);
if (autoDownload) {
const a = document.createElement("a");
// Use the direct API URL so Chrome sees a proper HTTP response with
// Content-Disposition: attachment headers, which bypasses the Safe
// Browsing pause that blob: URL downloads trigger on Linux/Chrome.
a.href = `/api/documents/pdf-files/${pdfId}`;
a.download = finalName;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}
catch (err) {
if (err && (err.name === "AbortError" || err.message === "The user aborted a request.")) {
return;
}
console.error("PdfPreviewModal fetch error:", err);
setError(err?.message ?? "Failed to fetch PDF");
}
finally {
setLoading(false);
}
};
fetchPdf();
return () => {
aborted = true;
controller.abort();
if (objectUrl)
URL.revokeObjectURL(objectUrl);
setFileBlobUrl(null);
setIsImage(false);
setError(null);
setLoading(false);
setResolvedFilename(null);
setIsFullscreen(false);
};
}, [open, pdfId, fallbackFilename]);
if (!open)
return null;
const handleDownload = () => {
if (!fileBlobUrl)
return;
const a = document.createElement("a");
a.href = fileBlobUrl;
a.download = resolvedFilename ?? `file_${pdfId ?? "unknown"}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const wrapperClass = isFullscreen
? "fixed inset-0 z-50 flex items-center justify-center bg-black/80"
: "fixed inset-0 z-50 flex items-center justify-center bg-black/50";
const containerClass = isFullscreen
? "bg-white w-full h-full rounded-none m-0 shadow-none flex flex-col"
: "bg-white rounded-lg shadow-lg w-11/12 md:w-3/4 lg:w-4/5 xl:w-3/4 h-5/6 flex flex-col";
return (<div className={wrapperClass}>
<div className={containerClass}>
<div className="flex items-center justify-between p-3 md:p-4 border-b">
<div className="flex flex-col">
<h3 className="text-lg md:text-xl font-semibold">
{resolvedFilename ?? "PDF Preview"}
</h3>
<p className="text-sm text-muted-foreground">{pdfId ? `ID: ${pdfId}` : ""}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsFullscreen((s) => !s)} title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}>
{isFullscreen ? <Minimize2 className="w-4 h-4"/> : <Maximize2 className="w-4 h-4"/>}
</Button>
<Button variant="ghost" onClick={handleDownload} disabled={!fileBlobUrl || loading}>
Download
</Button>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-2 md:p-4">
{loading && <div>Loading PDF</div>}
{error && <div className="text-destructive">Error: {error}</div>}
{fileBlobUrl && (isImage ? (<img src={fileBlobUrl} alt={resolvedFilename ?? "Preview"} className="max-w-full max-h-full object-contain mx-auto"/>) : (<iframe title="PDF Preview" src={fileBlobUrl} className="w-full h-full border" style={{ minHeight: 0 }}/>))}
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function TuftsSCOOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the verification code sent to your phone or email to complete this check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tufts-sco-otp">Verification Code</Label>
<Input id="tufts-sco-otp" placeholder="Enter verification code" value={otp} onChange={(e) => setOtp(e.target.value)} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit Code")}
</Button>
</div>
</form>
</div>
</div>);
}
export function TuftsSCOEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "TUFTS_SCO",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting Tufts SCO eligibility check…",
}));
const response = await apiRequest("POST", "/api/insurance-status-tuftssco/tuftssco-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Tufts SCO job queued. Waiting for browser session…",
}));
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Waiting for verification code or result…",
}));
};
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Verification code required. Please enter the code.",
}));
};
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Code submitted. Finishing eligibility check…",
}));
};
socket.on("selenium:dentaquest_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
function cleanup() {
clearTimeout(safetyTimer);
socket.off("selenium:dentaquest_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "Tufts SCO eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "Tufts SCO eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "Tufts SCO eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "Tufts SCO selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("job:update", onJobUpdate);
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "Tufts SCO job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
}
catch (err) {
console.error("TuftsSCOEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start Tufts SCO eligibility",
}));
toast({
title: "Tufts SCO selenium error",
description: err?.message || "Failed to start Tufts SCO eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit code — session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-tuftssco/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit code");
}
setOtpModalOpen(false);
}
catch (err) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit code",
description: err?.message || "Error forwarding code to selenium agent",
variant: "destructive",
});
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
Tufts SCO/SWH/Navi/Mass Gen
</>)}
</Button>
<TuftsSCOOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useAppDispatch } from "@/redux/hooks";
import { setTaskStatus } from "@/redux/slices/seleniumTaskSlice";
import { formatLocalDate } from "@/utils/dateUtils";
import { socket } from "@/lib/socket";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
function UnitedSCOOtpModal({ open, onClose, onSubmit, isSubmitting }) {
const [otp, setOtp] = useState("");
useEffect(() => {
if (!open)
setOtp("");
}, [open]);
if (!open)
return null;
const handleSubmit = async (e) => {
e.preventDefault();
if (!otp.trim())
return;
await onSubmit(otp.trim());
};
return (<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Enter OTP</h2>
<button type="button" onClick={onClose} className="text-slate-500 hover:text-slate-800">
<X className="w-4 h-4"/>
</button>
</div>
<p className="text-sm text-slate-500 mb-4">
We need the verification code sent to your phone or email to complete this check.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="united-sco-otp">Verification Code</Label>
<Input id="united-sco-otp" placeholder="Enter verification code" value={otp} onChange={(e) => setOtp(e.target.value)} autoFocus/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
{isSubmitting ? (<>
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin"/>
Submitting...
</>) : ("Submit Code")}
</Button>
</div>
</form>
</div>
</div>);
}
export function UnitedSCOEligibilityButton({ memberId, dateOfBirth, firstName, lastName, isFormIncomplete, autoTrigger, onAutoTriggered, onPdfReady, }) {
const { toast } = useToast();
const dispatch = useAppDispatch();
const sessionIdRef = useRef(null);
const autoTriggeredRef = useRef(false);
const [otpModalOpen, setOtpModalOpen] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
const handleStart = async () => {
if (!memberId || !dateOfBirth) {
toast({
title: "Missing fields",
description: "Member ID and Date of Birth are required.",
variant: "destructive",
});
return;
}
const formattedDob = formatLocalDate(dateOfBirth);
const payload = {
memberId,
dateOfBirth: formattedDob,
firstName,
lastName,
insuranceSiteKey: "UNITED_SCO",
};
setIsStarting(true);
try {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Starting United SCO eligibility check…",
}));
const response = await apiRequest("POST", "/api/insurance-status-unitedsco/unitedsco-eligibility", { data: JSON.stringify(payload), socketId: socket.id });
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || `Server error (${response.status})`);
}
const jobId = result.jobId;
if (!jobId)
throw new Error("No jobId returned from server");
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "United SCO job queued. Waiting for browser session…",
}));
const onSessionStarted = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
sessionIdRef.current = data.session_id ?? null;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Browser session started. Waiting for verification code or result…",
}));
};
const onOtpRequired = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.session_id)
sessionIdRef.current = data.session_id;
setOtpModalOpen(true);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Verification code required. Please enter the code.",
}));
};
const onOtpSubmitted = (data) => {
if (data?.session_id && data.session_id !== sessionIdRef.current)
return;
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: "Code submitted. Finishing eligibility check…",
}));
};
socket.on("selenium:unitedsco_session_started", onSessionStarted);
socket.on("selenium:otp_required", onOtpRequired);
socket.on("selenium:otp_submitted", onOtpSubmitted);
function cleanup() {
clearTimeout(safetyTimer);
socket.off("selenium:unitedsco_session_started", onSessionStarted);
socket.off("selenium:otp_required", onOtpRequired);
socket.off("selenium:otp_submitted", onOtpSubmitted);
socket.off("job:update", onJobUpdate);
}
const onJobUpdate = (data) => {
if (String(data?.jobId) !== String(jobId))
return;
if (data.status === "active") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "pending",
message: data.message ?? "Selenium browser starting…",
}));
return;
}
cleanup();
if (data.status === "completed") {
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "success",
message: "United SCO eligibility updated and PDF attached to patient documents.",
}));
toast({
title: "United SCO eligibility complete",
description: "Patient status was updated and the eligibility PDF was saved.",
});
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
const pdfId = data.result?.pdfFileId;
if (pdfId) {
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
}
}
else if (data.status === "failed") {
const msg = data.error ?? "United SCO eligibility job failed.";
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
toast({ title: "United SCO selenium error", description: msg, variant: "destructive" });
}
setIsStarting(false);
setOtpModalOpen(false);
};
socket.on("job:update", onJobUpdate);
const safetyTimer = setTimeout(() => {
cleanup();
setIsStarting(false);
setOtpModalOpen(false);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: "United SCO job timed out waiting for completion.",
}));
}, 6 * 60 * 1000);
}
catch (err) {
console.error("UnitedSCOEligibilityButton error:", err);
dispatch(setTaskStatus({
key: "eligibilityCheck",
status: "error",
message: err?.message || "Failed to start United SCO eligibility",
}));
toast({
title: "United SCO selenium error",
description: err?.message || "Failed to start United SCO eligibility",
variant: "destructive",
});
setIsStarting(false);
}
};
const handleSubmitOtp = async (otp) => {
const sessionId = sessionIdRef.current;
if (!sessionId) {
toast({
title: "Session not ready",
description: "Cannot submit code — session ID is not available yet.",
variant: "destructive",
});
return;
}
try {
setIsSubmittingOtp(true);
const resp = await apiRequest("POST", "/api/insurance-status-unitedsco/selenium/submit-otp", {
session_id: sessionId,
otp,
socketId: socket.id,
});
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || "Failed to submit code");
}
setOtpModalOpen(false);
}
catch (err) {
console.error("handleSubmitOtp error:", err);
toast({
title: "Failed to submit code",
description: err?.message || "Error forwarding code to selenium agent",
variant: "destructive",
});
}
finally {
setIsSubmittingOtp(false);
}
};
useEffect(() => {
if (!autoTrigger) {
autoTriggeredRef.current = false;
return;
}
if (autoTriggeredRef.current || isFormIncomplete)
return;
autoTriggeredRef.current = true;
onAutoTriggered?.();
handleStart();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoTrigger, isFormIncomplete]);
return (<>
<Button className="w-full" variant="default" disabled={isFormIncomplete || isStarting} onClick={handleStart}>
{isStarting ? (<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin"/>
Processing...
</>) : (<>
<CheckCircle className="h-4 w-4 mr-2"/>
United SCO
</>)}
</Button>
<UnitedSCOOtpModal open={otpModalOpen} onClose={() => setOtpModalOpen(false)} onSubmit={handleSubmitOtp} isSubmitting={isSubmittingOtp}/>
</>);
}

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog";
import { Eye, EyeOff } from "lucide-react";
export function CredentialsModal({ isOpen, onClose, onSubmit, providerName, isLoading = false, }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
if (username && password) {
onSubmit({ username, password });
}
};
const handleClose = () => {
setUsername("");
setPassword("");
setShowPassword(false);
onClose();
};
return (<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Insurance Portal Login</DialogTitle>
<DialogDescription>
Enter your credentials for {providerName} insurance portal to check
patient eligibility automatically.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input id="username" type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Enter your username" required disabled={isLoading}/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input id="password" type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Enter your password" required disabled={isLoading}/>
<Button type="button" variant="ghost" size="sm" className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" onClick={() => setShowPassword(!showPassword)} disabled={isLoading}>
{showPassword ? (<EyeOff className="h-4 w-4"/>) : (<Eye className="h-4 w-4"/>)}
</Button>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button type="button" variant="outline" onClick={handleClose} disabled={isLoading}>
Cancel
</Button>
<Button type="submit" disabled={!username || !password || isLoading}>
{isLoading ? "Checking..." : "Check Eligibility"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>);
}

View File

@@ -0,0 +1,22 @@
import { SidebarProvider } from "@/components/ui/sidebar";
import { Sidebar } from "@/components/layout/sidebar";
import { TopAppBar } from "@/components/layout/top-app-bar";
export default function AppLayout({ children }) {
return (<SidebarProvider defaultOpen>
<div className="flex flex-col h-screen">
{/* Fixed top bar */}
<TopAppBar />
{/* Main content area */}
<div className="flex flex-1 pt-16 min-h-0 bg-gray-100">
{/* Sidebar (collapsible on mobile) */}
<Sidebar />
{/* Page content */}
<main className="flex-1 min-w-0 min-h-0 overflow-y-auto p-4">
{children}
</main>
</div>
</div>
</SidebarProvider>);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import { Bell, Check, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
const PAGE_SIZE = 5;
export function NotificationsBell() {
const { toast } = useToast();
// dialog / pagination state (client-side over fetched 20)
const [open, setOpen] = useState(false);
const [pageIndex, setPageIndex] = useState(0); // 0..N (each page size 5)
// ------- Single load (no polling): fetch up to 20 latest notifications -------
const listQuery = useQuery({
queryKey: ["/notifications"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/notifications");
if (!res.ok)
throw new Error("Failed to fetch notifications");
return res.json();
},
refetchOnWindowFocus: false,
staleTime: Infinity,
gcTime: Infinity,
});
const all = listQuery.data ?? [];
const unread = useMemo(() => all.filter((n) => !n.read), [all]);
const unreadCount = unread.length;
// latest unread for spotlight
const latestUnread = unread[0] ?? null;
// client-side dialog pagination over the fetched 20
const totalPages = Math.max(1, Math.ceil(all.length / PAGE_SIZE));
const currentPageItems = useMemo(() => {
const start = pageIndex * PAGE_SIZE;
return all.slice(start, start + PAGE_SIZE);
}, [all, pageIndex]);
// ------- mutations -------
const markRead = useMutation({
mutationFn: async (id) => {
const res = await apiRequest("POST", `/api/notifications/${id}/read`);
if (!res.ok)
throw new Error("Failed to mark as read");
},
onMutate: async (id) => {
// optimistic update in cache
await queryClient.cancelQueries({ queryKey: ["/notifications"] });
const prev = queryClient.getQueryData(["/notifications"]);
if (prev) {
queryClient.setQueryData(["/notifications"], prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
}
return { prev };
},
onError: (_e, _id, ctx) => {
if (ctx?.prev)
queryClient.setQueryData(["/notifications"], ctx.prev);
toast({
title: "Error",
description: "Failed to update notification",
variant: "destructive",
});
},
});
const markAllRead = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/notifications/read-all");
if (!res.ok)
throw new Error("Failed to mark all as read");
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["/notifications"] });
const prev = queryClient.getQueryData(["/notifications"]);
if (prev) {
queryClient.setQueryData(["/notifications"], prev.map((n) => ({ ...n, read: true })));
}
return { prev };
},
onError: (_e, _id, ctx) => {
if (ctx?.prev)
queryClient.setQueryData(["/notifications"], ctx.prev);
toast({
title: "Error",
description: "Failed to mark all as read",
variant: "destructive",
});
},
});
// when opening dialog, reset to first page
const onOpenChange = async (v) => {
setOpen(v);
if (v) {
setPageIndex(0);
await listQuery.refetch();
}
};
return (<div className="relative">
{/* Bell + unread badge */}
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<button aria-label="Notifications" className="relative inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 transition">
<Bell className="h-6 w-6 text-gray-700"/>
{unreadCount > 0 && (<span className="absolute -top-0.5 -right-0.5 inline-flex min-w-5 h-5 items-center justify-center rounded-full text-xs font-semibold bg-red-600 text-white px-1">
{unreadCount > 99 ? "99+" : unreadCount}
</span>)}
</button>
</DialogTrigger>
{/* Dialog (client-side pagination over the 20 we already fetched) */}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Notifications</DialogTitle>
</DialogHeader>
{listQuery.isLoading ? (<div className="flex items-center justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin"/>
</div>) : all.length === 0 ? (<p className="text-sm text-gray-500">No notifications yet.</p>) : (<>
<div className="space-y-2 max-h-80 overflow-y-auto">
{currentPageItems.map((n) => (<div key={n.id} className="flex items-start justify-between gap-3 rounded-lg border p-3 hover:bg-gray-50 transition">
<div className="flex-1">
<p className="text-sm">{n.message}</p>
<p className="mt-1 text-xs text-gray-500">
{formatDateToHumanReadable(n.createdAt)}
</p>
</div>
{!n.read ? (<Button size="sm" variant="secondary" onClick={() => markRead.mutate(Number(n.id))} disabled={markRead.isPending}>
{markRead.isPending ? (<Loader2 className="h-4 w-4 animate-spin"/>) : (<Check className="h-4 w-4 mr-1"/>)}
Mark read
</Button>) : (<span className="text-xs text-gray-400">Read</span>)}
</div>))}
</div>
<div className="mt-3 flex items-center justify-between">
<Button variant="outline" size="sm" disabled={pageIndex === 0} onClick={() => setPageIndex((p) => Math.max(0, p - 1))}>
Prev
</Button>
<div className="text-xs text-gray-500">
Page {pageIndex + 1} / {totalPages}
</div>
<Button variant="outline" size="sm" disabled={pageIndex >= totalPages - 1} onClick={() => setPageIndex((p) => p + 1)}>
Next
</Button>
</div>
<div className="mt-2 flex justify-end">
<Button variant="ghost" size="sm" onClick={() => markAllRead.mutate()} disabled={markAllRead.isPending}>
{markAllRead.isPending && (<Loader2 className="h-4 w-4 animate-spin mr-2"/>)}
Mark all as read
</Button>
</div>
</>)}
</DialogContent>
</Dialog>
{/* Spotlight: ONE latest unread (animates in; collapses when marked read) */}
<AnimatePresence>
{latestUnread && (<motion.div key={latestUnread.id} initial={{ opacity: 0, scale: 0.9, y: -6, filter: "blur(6px)" }} animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.95, y: -6, filter: "blur(6px)" }} transition={{ type: "spring", stiffness: 220, damping: 22 }} className="absolute z-50 top-12 right-0 w-[min(92vw,28rem)]">
<div className="relative overflow-hidden rounded-2xl border shadow-xl bg-white">
{/* animated halo */}
<motion.div initial={{ opacity: 0.15, scale: 0.8 }} animate={{ opacity: [0.15, 0.35, 0.15], scale: [0.8, 1, 0.8] }} transition={{
duration: 2.2,
repeat: Infinity,
ease: "easeInOut",
}} className="pointer-events-none absolute inset-0 bg-yellow-200" style={{ mixBlendMode: "multiply" }}/>
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">
{/* ping dot */}
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75"/>
<span className="relative inline-flex rounded-full h-3 w-3 bg-yellow-500"/>
</span>
</div>
<div className="flex-1">
<p className="text-sm text-gray-900">
{latestUnread.message}
</p>
<p className="mt-1 text-xs text-gray-500">
{formatDateToHumanReadable(latestUnread.createdAt)}
</p>
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => markRead.mutate(Number(latestUnread.id))} disabled={markRead.isPending}>
{markRead.isPending ? (<Loader2 className="h-4 w-4 animate-spin"/>) : (<Check className="h-4 w-4 mr-1"/>)}
Mark as read
</Button>
</div>
</div>
</div>
</motion.div>)}
</AnimatePresence>
</div>);
}

View File

@@ -0,0 +1,302 @@
import { Link, useLocation } from "wouter";
import { LayoutDashboard, Users, Calendar, Settings, FileCheck, Shield, CreditCard, FolderOpen, Database, FileText, Cloud, Phone, Activity, ClipboardList, LayoutGrid, ListChecks, Pill, Microscope, ChevronDown, ChevronRight, UserCog, User, ShieldCheck, Stethoscope, Workflow, Bot, Clock, Building2, Timer, BookOpen, ShoppingCart, Search, KeyRound, } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMemo, useState, useEffect } from "react";
import { useSidebar } from "@/components/ui/sidebar";
import { useAuth } from "@/hooks/use-auth";
export function Sidebar() {
const [location] = useLocation();
const { state, openMobile, setOpenMobile } = useSidebar();
const { user } = useAuth();
const isAdmin = user?.username === "admin";
const [expandedPaths, setExpandedPaths] = useState(() => {
const s = new Set();
if (location.startsWith("/chart"))
s.add("/chart");
if (location.startsWith("/settings"))
s.add("/settings");
if (location.startsWith("/dental-shopping"))
s.add("/dental-shopping");
return s;
});
useEffect(() => {
if (location.startsWith("/chart")) {
setExpandedPaths((prev) => new Set([...prev, "/chart"]));
}
if (location.startsWith("/settings")) {
setExpandedPaths((prev) => new Set([...prev, "/settings"]));
}
if (location.startsWith("/dental-shopping")) {
setExpandedPaths((prev) => new Set([...prev, "/dental-shopping"]));
}
}, [location]);
const togglePath = (path) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path))
next.delete(path);
else
next.add(path);
return next;
});
};
const navItems = useMemo(() => [
{
name: "Dashboard",
path: "/dashboard",
icon: <LayoutDashboard className="h-5 w-5 text-violet-500"/>,
},
{
name: "Patient Connection",
path: "/patient-connection",
icon: <Phone className="h-5 w-5 text-green-500"/>,
},
{
name: "Schedule",
path: "/appointments",
icon: <Calendar className="h-5 w-5 text-blue-500"/>,
},
{
name: "Patient Management",
path: "/patients",
icon: <Users className="h-5 w-5 text-indigo-500"/>,
},
{
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 text-emerald-500"/>,
},
{
name: "Claims/PreAuth",
path: "/claims",
icon: <FileCheck className="h-5 w-5 text-orange-500"/>,
},
{
name: "Accounts/Payments",
path: "/payments",
icon: <CreditCard className="h-5 w-5 text-amber-500"/>,
},
{
name: "Documents",
path: "/documents",
icon: <FolderOpen className="h-5 w-5 text-yellow-500"/>,
},
{
name: "Reports",
path: "/reports",
icon: <FileText className="h-5 w-5 text-red-400"/>,
},
{
name: "Cloud storage",
path: "/cloud-storage",
icon: <Cloud className="h-5 w-5 text-sky-500"/>,
},
{
name: "AI Input Agent",
path: "/ai-input-agent",
icon: <Bot className="h-5 w-5 text-violet-500"/>,
},
{
name: "AI Dental Shopping",
path: "/dental-shopping",
icon: <ShoppingCart className="h-5 w-5 text-cyan-500"/>,
children: [
{
name: "Search / Tag",
path: "/dental-shopping/search-tag",
icon: <Search className="h-4 w-4 text-cyan-400"/>,
},
{
name: "Login Info",
path: "/dental-shopping/login-info",
icon: <KeyRound className="h-4 w-4 text-cyan-400"/>,
},
],
},
{
name: "Activation",
path: "/activation",
icon: <KeyRound className="h-5 w-5 text-amber-500"/>,
adminOnly: true,
},
{
name: "Database Management",
path: "/database-management",
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 text-rose-500"/>,
adminOnly: true,
},
{
name: "Settings",
path: "/settings",
icon: <Settings className="h-5 w-5 text-gray-400"/>,
adminOnly: true,
children: [
// ── General ──────────────────────────────────────────
{
groupLabel: "General",
name: "Staff Management",
path: "/settings/staff",
icon: <Users className="h-4 w-4 text-gray-400"/>,
},
{
name: "Manage Users",
path: "/settings/users",
icon: <UserCog className="h-4 w-4 text-gray-400"/>,
adminOnly: true,
},
{
name: "Account Settings",
path: "/settings/account",
icon: <User className="h-4 w-4 text-gray-400"/>,
},
{
name: "Insurance Credentials",
path: "/settings/credentials",
icon: <ShieldCheck className="h-4 w-4 text-gray-400"/>,
},
{
name: "NPI Providers",
path: "/settings/npi",
icon: <Stethoscope className="h-4 w-4 text-gray-400"/>,
},
{
name: "Program Bridge",
path: "/settings/programs",
icon: <Workflow className="h-4 w-4 text-gray-400"/>,
},
// ── Advanced ─────────────────────────────────────────
{
groupLabel: "Advanced",
name: "Office Hours",
path: "/settings/officehours",
icon: <Clock className="h-4 w-4 text-gray-400"/>,
},
{
name: "Office Contact",
path: "/settings/officecontact",
icon: <Building2 className="h-4 w-4 text-gray-400"/>,
},
{
name: "Insurance/Transportation Contact",
path: "/settings/insurancecontact",
icon: <BookOpen className="h-4 w-4 text-gray-400"/>,
},
{
name: "Procedure Duration/Time Slot",
path: "/settings/proceduretimeslot",
icon: <Timer className="h-4 w-4 text-gray-400"/>,
},
{
name: "Twilio Settings",
path: "/settings/twilio",
icon: <Phone className="h-4 w-4 text-gray-400"/>,
},
{
name: "AI API Setting",
path: "/settings/ai",
icon: <Bot className="h-4 w-4 text-gray-400"/>,
},
{
name: "AI Chat Settings",
path: "/settings/aichat",
icon: <Bot className="h-4 w-4 text-gray-400"/>,
},
],
},
], []);
return (<div className={cn("bg-white border-r border-gray-200 shadow-sm z-20", "overflow-x-hidden will-change-[width]", "transition-[width] duration-200 ease-in-out", openMobile
? "fixed top-16 left-0 h-[calc(100vh-4rem)] w-64 block md:hidden"
: "hidden md:block", "md:static md:top-auto md:h-full md:flex-shrink-0", state === "collapsed" ? "md:w-0 overflow-hidden" : "md:w-64")}>
<div className="p-2 h-full overflow-y-auto">
<nav role="navigation" aria-label="Main">
{navItems
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
if (item.children) {
const isParentActive = location.startsWith(item.path);
const isExpanded = expandedPaths.has(item.path);
const visibleChildren = item.children.filter((c) => !c.adminOnly || isAdmin);
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">
{visibleChildren.map((child) => {
const isActive = location === child.path ||
location.startsWith(child.path + "/");
return (<div key={child.path}>
{child.groupLabel && (<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 px-2 pt-2 pb-0.5">
{child.groupLabel}
</p>)}
<Link to={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>)}
</div>);
}
return (<div key={item.path}>
<Link to={item.path} onClick={() => setOpenMobile(false)}>
<div className={cn("flex items-center space-x-3 p-2 rounded-md pl-3 mb-1 transition-colors cursor-pointer", location === item.path
? "text-primary font-medium border-l-2 border-primary"
: "text-gray-600 hover:bg-gray-100")}>
{item.icon}
<span className="whitespace-nowrap select-none">
{item.name}
</span>
</div>
</Link>
</div>);
})}
</nav>
</div>
</div>);
}

View File

@@ -0,0 +1,64 @@
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useAuth } from "@/hooks/use-auth";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu";
import { Link, useLocation } from "wouter";
import { NotificationsBell } from "@/components/layout/notification-bell";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { ChatbotButton } from "@/components/layout/chatbot";
export function TopAppBar() {
const { user, logoutMutation } = useAuth();
const [location, setLocation] = useLocation();
const handleLogout = () => logoutMutation.mutate();
const getInitials = (username) => username.substring(0, 2).toUpperCase();
return (<header className="bg-white shadow-sm z-30 fixed top-0 left-0 right-0">
<div className="flex items-center justify-between h-16 px-4">
<div className="flex items-center">
{/* both desktop + mobile triggers */}
<SidebarTrigger className="mr-2"/>
<Link to="/dashboard">
<div className="p-4 border-gray-200 flex items-center space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5 text-primary">
<path d="M12 14c-1.65 0-3-1.35-3-3V5c0-1.65 1.35-3 3-3s3 1.35 3 3v6c0 1.65-1.35 3-3 3Z"/>
<path d="M19 14v-4a7 7 0 0 0-14 0v4"/>
<path d="M12 19c-5 0-8-2-9-5.5m18 0c-1 3.5-4 5.5-9 5.5Z"/>
</svg>
<h1 className="text-lg font-medium text-primary">
My Dental Office Management
</h1>
</div>
</Link>
</div>
<div className="flex items-center space-x-3">
<NotificationsBell />
<ChatbotButton />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative p-0 h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src="" alt={user?.username}/>
<AvatarFallback className="bg-primary text-white">
{user?.username ? getInitials(user.username) : "U"}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>{user?.username}</DropdownMenuItem>
<DropdownMenuItem>My Profile</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocation("/settings")}>
Account Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>);
}

View File

@@ -0,0 +1,200 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Device } from "@twilio/voice-sdk";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Phone, PhoneOff, Mic, MicOff, Delete } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
const KEYS = [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
["*", "0", "#"],
];
function formatDuration(seconds) {
const m = Math.floor(seconds / 60).toString().padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}
export function DialPad() {
const [dialedNumber, setDialedNumber] = useState("");
const [status, setStatus] = useState("idle");
const [isMuted, setIsMuted] = useState(false);
const [duration, setDuration] = useState(0);
const { toast } = useToast();
const deviceRef = useRef(null);
const callRef = useRef(null);
const timerRef = useRef(null);
const stopTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const destroyDevice = useCallback(() => {
callRef.current?.disconnect();
callRef.current = null;
deviceRef.current?.destroy();
deviceRef.current = null;
}, []);
useEffect(() => {
return () => {
stopTimer();
destroyDevice();
};
}, [stopTimer, destroyDevice]);
// Keyboard input support
useEffect(() => {
const handleKey = (e) => {
if (status !== "idle")
return;
const valid = "0123456789*#";
if (valid.includes(e.key)) {
setDialedNumber((n) => (n.length < 16 ? n + e.key : n));
}
else if (e.key === "Backspace") {
setDialedNumber((n) => n.slice(0, -1));
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [status]);
const pressKey = (key) => {
if (status !== "idle")
return;
setDialedNumber((n) => (n.length < 16 ? n + key : n));
};
const handleBackspace = () => {
if (status !== "idle")
return;
setDialedNumber((n) => n.slice(0, -1));
};
const handleCall = async () => {
if (!dialedNumber.trim()) {
toast({ title: "Enter a number", description: "Please dial a phone number first.", variant: "destructive" });
return;
}
setStatus("requesting-token");
setDuration(0);
setIsMuted(false);
try {
const res = await apiRequest("POST", "/api/twilio/voice-token");
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to get voice token");
}
const { token } = await res.json();
const device = new Device(token, { logLevel: "error" });
deviceRef.current = device;
await device.register();
setStatus("connecting");
// Normalize to E.164 if it looks like a 10-digit US number
let toNumber = dialedNumber.replace(/\D/g, "");
if (toNumber.length === 10)
toNumber = "+1" + toNumber;
else if (!toNumber.startsWith("+"))
toNumber = "+" + toNumber;
const call = await device.connect({ params: { To: toNumber } });
callRef.current = call;
call.on("accept", () => {
setStatus("connected");
timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000);
});
call.on("disconnect", () => {
stopTimer();
destroyDevice();
setStatus("idle");
setDuration(0);
setIsMuted(false);
});
call.on("error", (err) => {
stopTimer();
destroyDevice();
setStatus("error");
toast({ title: "Call Error", description: err?.message || "Call failed.", variant: "destructive" });
setTimeout(() => setStatus("idle"), 3000);
});
}
catch (err) {
destroyDevice();
setStatus("error");
toast({ title: "Call Failed", description: err?.message || "Unable to place call.", variant: "destructive" });
setTimeout(() => setStatus("idle"), 3000);
}
};
const handleHangup = () => {
callRef.current?.disconnect();
};
const handleMute = () => {
if (!callRef.current)
return;
const next = !isMuted;
callRef.current.mute(next);
setIsMuted(next);
};
const statusLabel = {
idle: "Ready",
"requesting-token": "Initializing...",
connecting: "Connecting...",
connected: `In Call ${formatDuration(duration)}`,
error: "Error",
};
const statusColor = {
idle: "text-muted-foreground",
"requesting-token": "text-amber-500",
connecting: "text-amber-500",
connected: "text-green-600",
error: "text-red-500",
};
const isInCall = status === "connected" || status === "connecting";
const isBusy = status === "requesting-token" || status === "connecting";
return (<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Phone className="h-4 w-4"/>
Dial Pad
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center gap-3 max-w-xs mx-auto">
{/* Number display */}
<div className="w-full flex items-center gap-2 px-3 py-2 border rounded-lg bg-gray-50 min-h-[42px]">
<span className="flex-1 font-mono text-lg tracking-widest text-center">
{dialedNumber || <span className="text-muted-foreground text-sm">Enter number</span>}
</span>
{dialedNumber && status === "idle" && (<button onClick={handleBackspace} className="text-muted-foreground hover:text-foreground">
<Delete className="h-4 w-4"/>
</button>)}
</div>
{/* Keypad */}
<div className="grid grid-cols-3 gap-2 w-full">
{KEYS.flat().map((key) => (<button key={key} onClick={() => pressKey(key)} disabled={isInCall || isBusy} className="h-12 rounded-lg border bg-white text-lg font-medium hover:bg-gray-50 active:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
{key}
</button>))}
</div>
{/* Call / Hangup */}
<div className="flex gap-3 w-full">
{!isInCall ? (<Button className="flex-1 bg-green-600 hover:bg-green-700 text-white" onClick={handleCall} disabled={isBusy || !dialedNumber.trim()}>
<Phone className="h-4 w-4 mr-2"/>
Call
</Button>) : (<>
<Button variant="outline" onClick={handleMute} className={isMuted ? "border-red-300 text-red-600" : ""}>
{isMuted ? <MicOff className="h-4 w-4"/> : <Mic className="h-4 w-4"/>}
</Button>
<Button className="flex-1 bg-red-600 hover:bg-red-700 text-white" onClick={handleHangup}>
<PhoneOff className="h-4 w-4 mr-2"/>
Hang Up
</Button>
</>)}
</div>
{/* Status */}
<p className={`text-sm font-medium ${statusColor[status]}`}>
{statusLabel[status]}
</p>
</div>
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,485 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { format, isToday, isYesterday, parseISO } from "date-fns";
import { es, pt, zhCN, zhTW, ar, fr } from "date-fns/locale";
// ─── Language config ──────────────────────────────────────────────────────────
export const SUPPORTED_LANGUAGES = [
"English",
"Spanish",
"Portuguese",
"Mandarin",
"Cantonese",
"Arabic",
"Haitian Creole",
];
const LOCALES = {
Spanish: es,
Portuguese: pt,
Mandarin: zhCN,
Cantonese: zhTW,
Arabic: ar,
"Haitian Creole": fr, // French locale — closest approximation for day/month names
};
// Date pattern per language (date-fns format tokens)
const DATE_FMT = {
English: "MMMM do, EEEE",
Spanish: "EEEE, d 'de' MMMM",
Portuguese: "EEEE, d 'de' MMMM",
Mandarin: "M月d日EEEE",
Cantonese: "M月d日EEEE",
Arabic: "EEEE d MMMM",
"Haitian Creole": "EEEE d MMMM",
};
function formatTime(h, m, lang) {
const pad = (n) => String(n).padStart(2, "0");
const h12 = h % 12 || 12;
if (lang === "Mandarin" || lang === "Cantonese") {
return `${h >= 12 ? "下午" : "上午"}${h12}:${pad(m)}`;
}
if (lang === "Arabic") {
return `${h12}:${pad(m)} ${h >= 12 ? "مساءً" : "صباحاً"}`;
}
if (lang === "Spanish" || lang === "Portuguese") {
return `${pad(h)}:${pad(m)}`;
}
// English, Haitian Creole
return `${h12}:${pad(m)} ${h >= 12 ? "pm" : "am"}`;
}
function buildApptPhrase(date, startTime, lang) {
const [y, mo, d] = date.split("-").map(Number);
const dateObj = new Date(y, mo - 1, d);
const locale = LOCALES[lang];
const datePart = format(dateObj, DATE_FMT[lang], { locale });
const [h, m] = startTime.split(":").map(Number);
const timePart = formatTime(h, m, lang);
switch (lang) {
case "Spanish": return `el ${datePart} a las ${timePart}`;
case "Portuguese": return `na ${datePart} às ${timePart}`;
case "Mandarin": return `${datePart}${timePart}`;
case "Cantonese": return `${datePart}${timePart}`;
case "Arabic": return `في ${datePart} الساعة ${timePart}`;
case "Haitian Creole": return `le ${datePart} a ${timePart}`;
default: return `on ${datePart} at ${timePart}`;
}
}
const DEFAULT_OFFICE_NAME = {
English: "the dental office",
Spanish: "la clínica dental",
Portuguese: "o consultório dentário",
Mandarin: "牙科诊所",
Cantonese: "牙科診所",
Arabic: "عيادة الأسنان",
"Haitian Creole": "kabinè dantis la",
};
function callPhrase(phone, lang) {
if (!phone?.trim()) {
return { English: "call us", Spanish: "llámenos", Portuguese: "ligue-nos",
Mandarin: "联系我们", Cantonese: "聯繫我們", Arabic: "اتصل بنا",
"Haitian Creole": "rele nou" }[lang];
}
return {
English: `call us at ${phone}`,
Spanish: `llámenos al ${phone}`,
Portuguese: `ligue-nos pelo ${phone}`,
Mandarin: `致电 ${phone}`,
Cantonese: `致電 ${phone}`,
Arabic: `اتصل بنا على ${phone}`,
"Haitian Creole": `rele nou nan ${phone}`,
}[lang];
}
function buildTemplates(patient, lang, apptInfo, office) {
const n = patient.firstName;
const o = office?.officeName?.trim() || DEFAULT_OFFICE_NAME[lang];
const c = callPhrase(office?.phoneNumber, lang);
const appt = apptInfo ? buildApptPhrase(apptInfo.date, apptInfo.startTime, lang) : "";
const T = {
English: {
label_reminder: "Appointment Reminder",
label_confirmed: "Appointment Confirmed",
label_followup: "Follow-Up",
label_payment: "Payment Reminder",
label_general: "General",
reminder: apptInfo
? `Hi ${n}, this is ${o}. Reminder: You have an appointment scheduled ${appt}. Please confirm through text messages or ${c} if you need to reschedule.`
: `Hi ${n}, this is ${o}. Reminder: You have an upcoming appointment. Please confirm through text messages or ${c} if you need to reschedule.`,
confirmed: `Hi ${n}, this is ${o}. Your appointment has been confirmed. We look forward to seeing you! If you have any questions, please ${c}.`,
followup: `Hi ${n}, this is ${o}. Thank you for visiting us. How are you feeling after your treatment? Please let us know if you have any concerns.`,
payment: `Hi ${n}, this is ${o}. This is a friendly reminder about your outstanding balance. Please ${c} to discuss payment options.`,
general: `Hi ${n}, this is ${o}. `,
},
Spanish: {
label_reminder: "Recordatorio de cita",
label_confirmed: "Cita confirmada",
label_followup: "Seguimiento",
label_payment: "Recordatorio de pago",
label_general: "General",
reminder: apptInfo
? `Hola ${n}, le habla ${o}. Recordatorio: Tiene una cita programada ${appt}. Por favor confirme por mensaje de texto o ${c} si necesita reprogramar.`
: `Hola ${n}, le habla ${o}. Recordatorio: Tiene una próxima cita. Por favor confirme por mensaje de texto o ${c} si necesita reprogramar.`,
confirmed: `Hola ${n}, le habla ${o}. Su cita ha sido confirmada. ¡Esperamos verle pronto! Si tiene alguna pregunta, por favor ${c}.`,
followup: `Hola ${n}, le habla ${o}. Gracias por visitarnos. ¿Cómo se siente después de su tratamiento? Por favor, háganos saber si tiene alguna inquietud.`,
payment: `Hola ${n}, le habla ${o}. Este es un recordatorio amable sobre su saldo pendiente. Por favor ${c} para hablar sobre las opciones de pago.`,
general: `Hola ${n}, le habla ${o}. `,
},
Portuguese: {
label_reminder: "Lembrete de consulta",
label_confirmed: "Consulta confirmada",
label_followup: "Acompanhamento",
label_payment: "Lembrete de pagamento",
label_general: "Geral",
reminder: apptInfo
? `Olá ${n}, aqui é ${o}. Lembrete: Você tem uma consulta agendada ${appt}. Por favor confirme por mensagem de texto ou ${c} se precisar reagendar.`
: `Olá ${n}, aqui é ${o}. Lembrete: Você tem uma consulta próxima. Por favor confirme por mensagem de texto ou ${c} se precisar reagendar.`,
confirmed: `Olá ${n}, aqui é ${o}. Sua consulta foi confirmada. Aguardamos sua visita! Se tiver alguma dúvida, por favor ${c}.`,
followup: `Olá ${n}, aqui é ${o}. Obrigado pela sua visita. Como está se sentindo após o tratamento? Por favor, nos informe se tiver alguma preocupação.`,
payment: `Olá ${n}, aqui é ${o}. Este é um lembrete amigável sobre o seu saldo pendente. Por favor ${c} para discutir as opções de pagamento.`,
general: `Olá ${n}, aqui é ${o}. `,
},
Mandarin: {
label_reminder: "预约提醒",
label_confirmed: "预约确认",
label_followup: "回访",
label_payment: "付款提醒",
label_general: "一般消息",
reminder: apptInfo
? `您好 ${n},我们是${o}。提醒:您有一个预约安排${appt}。请通过短信确认,或如需重新安排请${c}`
: `您好 ${n},我们是${o}。提醒:您有一个即将到来的预约。请通过短信确认,或如需重新安排请${c}`,
confirmed: `您好 ${n},我们是${o}。您的预约已确认。期待您的光临!如有疑问,请${c}`,
followup: `您好 ${n},我们是${o}。感谢您的来访。治疗后您感觉怎么样?如有任何疑虑,请告诉我们。`,
payment: `您好 ${n},我们是${o}。这是关于您未付余额的友好提醒。请${c}讨论付款方案。`,
general: `您好 ${n},我们是${o}`,
},
Cantonese: {
label_reminder: "預約提醒",
label_confirmed: "預約確認",
label_followup: "跟進",
label_payment: "付款提醒",
label_general: "一般消息",
reminder: apptInfo
? `您好 ${n},我哋係${o}。提醒:您有一個預約安排${appt}。請透過短訊確認,或如需重新安排請${c}`
: `您好 ${n},我哋係${o}。提醒:您有一個即將到嘅預約。請透過短訊確認,或如需重新安排請${c}`,
confirmed: `您好 ${n},我哋係${o}。您嘅預約已確認。期待見到您!如有疑問,請${c}`,
followup: `您好 ${n},我哋係${o}。感謝您嘅到訪。治療後您感覺點樣?如有任何疑慮,請告知我們。`,
payment: `您好 ${n},我哋係${o}。呢係關於您未付餘額嘅友好提醒。請${c}討論付款方案。`,
general: `您好 ${n},我哋係${o}`,
},
Arabic: {
label_reminder: "تذكير بالموعد",
label_confirmed: "تأكيد الموعد",
label_followup: "متابعة",
label_payment: "تذكير بالدفع",
label_general: "رسالة عامة",
reminder: apptInfo
? `مرحباً ${n}، نحن ${o}. تذكير: لديك موعد مجدول ${appt}. يرجى التأكيد عبر الرسائل النصية أو ${c} إذا كنت بحاجة إلى إعادة الجدولة.`
: `مرحباً ${n}، نحن ${o}. تذكير: لديك موعد قادم. يرجى التأكيد عبر الرسائل النصية أو ${c} إذا كنت بحاجة إلى إعادة الجدولة.`,
confirmed: `مرحباً ${n}، نحن ${o}. تم تأكيد موعدك. نتطلع إلى رؤيتك! إذا كان لديك أي أسئلة، يرجى ${c}.`,
followup: `مرحباً ${n}، نحن ${o}. شكراً لزيارتك. كيف تشعر بعد العلاج؟ يرجى إبلاغنا إذا كان لديك أي مخاوف.`,
payment: `مرحباً ${n}، نحن ${o}. هذا تذكير ودي بشأن رصيدك المستحق. يرجى ${c} لمناقشة خيارات الدفع.`,
general: `مرحباً ${n}، نحن ${o}. `,
},
"Haitian Creole": {
label_reminder: "Rapèl randevou",
label_confirmed: "Randevou konfime",
label_followup: "Swivi",
label_payment: "Rapèl peman",
label_general: "Jeneral",
reminder: apptInfo
? `Bonjou ${n}, se ${o} k'ap pale. Rapèl: Ou gen yon randevou pwograme ${appt}. Tanpri konfime pa mesaj tèks oswa ${c} si ou bezwen repwograme.`
: `Bonjou ${n}, se ${o} k'ap pale. Rapèl: Ou gen yon randevou k'ap vini. Tanpri konfime pa mesaj tèks oswa ${c} si ou bezwen repwograme.`,
confirmed: `Bonjou ${n}, se ${o} k'ap pale. Randevou ou a konfime. N'ap tann ou! Si ou gen kesyon, tanpri ${c}.`,
followup: `Bonjou ${n}, se ${o} k'ap pale. Mèsi dèske ou te vizite nou. Kijan ou santi ou apre tretman ou? Tanpri fè nou konnen si ou gen nenpòt enkyetid.`,
payment: `Bonjou ${n}, se ${o} k'ap pale. Sa se yon rapèl amical sou balans ou ki annatant. Tanpri ${c} pou diskite opsyon peman.`,
general: `Bonjou ${n}, se ${o} k'ap pale. `,
},
}[lang];
return [
{ key: "appointment_reminder", label: T.label_reminder, body: T.reminder },
{ key: "appointment_confirmed", label: T.label_confirmed, body: T.confirmed },
{ key: "follow_up", label: T.label_followup, body: T.followup },
{ key: "payment_reminder", label: T.label_payment, body: T.payment },
{ key: "general", label: T.label_general, body: T.general },
];
}
export function MessageThread({ patient, onBack, appointmentInfo }) {
const { toast } = useToast();
const [messageText, setMessageText] = useState("");
const [language, setLanguage] = useState(patient.preferredLanguage &&
SUPPORTED_LANGUAGES.includes(patient.preferredLanguage)
? patient.preferredLanguage
: "English");
const messagesEndRef = useRef(null);
const [handOffToAI, setHandOffToAI] = useState(true);
const [pendingStartFlow, setPendingStartFlow] = useState(null);
useQuery({
queryKey: ["/api/twilio/ai-handoff", patient.id],
queryFn: async () => {
const res = await apiRequest("GET", `/api/twilio/ai-handoff/${patient.id}`);
return res.json();
},
onSuccess: (data) => setHandOffToAI(data.enabled),
});
const { data: aiChatTemplates } = useQuery({
queryKey: ["/api/ai/chat-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/chat-templates");
if (!res.ok)
return null;
return res.json();
},
staleTime: 60_000,
});
const handoffMutation = useMutation({
mutationFn: async (enabled) => apiRequest("PUT", `/api/twilio/ai-handoff/${patient.id}`, { enabled }),
});
const handleHandoffToggle = (enabled) => {
setHandOffToAI(enabled);
handoffMutation.mutate(enabled);
};
const { data: officeContact } = useQuery({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok)
return null;
return res.json();
},
staleTime: 60_000,
});
const { data: communications = [], isLoading } = useQuery({
queryKey: ["/api/patients", patient.id, "communications"],
queryFn: async () => {
const res = await apiRequest("GET", `/api/patients/${patient.id}/communications`);
return res.json();
},
refetchInterval: 5000,
});
const sendMessageMutation = useMutation({
mutationFn: async (message) => apiRequest("POST", "/api/twilio/send-sms", {
to: patient.phone,
message,
patientId: patient.id,
...(pendingStartFlow ? { startFlow: pendingStartFlow } : {}),
}),
onSuccess: () => {
setMessageText("");
setPendingStartFlow(null);
queryClient.invalidateQueries({ queryKey: ["/api/patients", patient.id, "communications"] });
toast({ title: "Message sent", description: "Your message has been sent successfully." });
},
onError: (error) => {
toast({
title: "Failed to send message",
description: error.message || "Unable to send message. Please try again.",
variant: "destructive",
});
},
});
const handleSendMessage = () => {
if (!messageText.trim())
return;
sendMessageMutation.mutate(messageText);
};
const handleKeyPress = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [communications]);
const formatMessageDate = (dateValue) => {
const date = typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(date))
return format(date, "h:mm a");
if (isYesterday(date))
return `Yesterday ${format(date, "h:mm a")}`;
return format(date, "MMM d, h:mm a");
};
const getDateDivider = (dateValue) => {
const d = typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
if (isToday(d))
return "Today";
if (isYesterday(d))
return "Yesterday";
return format(d, "MMMM d, yyyy");
};
const groupedMessages = [];
communications.forEach((comm) => {
if (!comm.createdAt)
return;
const messageDate = typeof comm.createdAt === "string" ? parseISO(comm.createdAt) : comm.createdAt;
const dateKey = format(messageDate, "yyyy-MM-dd");
const existing = groupedMessages.find((g) => g.date === dateKey);
if (existing)
existing.messages.push(comm);
else
groupedMessages.push({ date: dateKey, messages: [comm] });
});
const templates = buildTemplates(patient, language, appointmentInfo, officeContact);
return (<div className="flex flex-col h-full bg-white rounded-lg border">
{/* Header */}
<div className="p-4 border-b bg-gray-50 space-y-2">
{/* Patient identity row */}
<div className="flex items-center gap-3">
{onBack && (<Button variant="ghost" size="icon" onClick={onBack} data-testid="button-back">
<ArrowLeft className="h-5 w-5"/>
</Button>)}
<div className="h-10 w-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold flex-shrink-0">
{patient.firstName[0]}{patient.lastName[0]}
</div>
<div>
<h3 className="font-semibold text-base">
{patient.firstName} {patient.lastName}
</h3>
<p className="text-sm text-muted-foreground">{patient.phone}</p>
</div>
</div>
{/* Controls row: language + template */}
<div className="flex items-center gap-2 pl-1 flex-wrap">
{/* Language selector */}
<div className="flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0"/>
<Select value={language} onValueChange={(v) => setLanguage(v)}>
<SelectTrigger className="h-7 text-xs w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((l) => (<SelectItem key={l} value={l} className="text-xs">{l}</SelectItem>))}
</SelectContent>
</Select>
</div>
{/* Template selector */}
<div className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0"/>
<Select value="" onValueChange={(key) => {
if (key === "__new_patient__") {
const greeting = aiChatTemplates?.newPatientGreeting ||
"Hi! My name is Lisa, the dedicated AI assistant at our dental office. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?";
setMessageText(greeting);
setPendingStartFlow("new_patient");
}
else if (key === "__reschedule__") {
const greeting = aiChatTemplates?.rescheduleGreeting ||
"Hi! My name is Lisa, the dedicated AI assistant at our dental office. I can help you find a new appointment time that works for you. Would you like to reschedule your appointment?";
setMessageText(greeting);
setPendingStartFlow("reschedule");
}
else {
const tpl = templates.find((t) => t.key === key);
if (tpl) {
setMessageText(tpl.body);
setPendingStartFlow(null);
}
}
}}>
<SelectTrigger className="h-7 text-xs border-dashed w-[180px]">
<SelectValue placeholder="Use a template…"/>
</SelectTrigger>
<SelectContent>
{/* New patient scheduling — uses AI New Patient Greeting */}
<SelectItem value="__new_patient__" className="text-xs font-medium text-primary">
<span className="flex items-center gap-1.5">
<UserPlus className="h-3 w-3"/>
Schedule a New Patient
</span>
</SelectItem>
{/* Reschedule patients — uses AI Reschedule Greeting */}
<SelectItem value="__reschedule__" className="text-xs font-medium text-primary">
<span className="flex items-center gap-1.5">
<CalendarX className="h-3 w-3"/>
Reschedule Patients
</span>
</SelectItem>
<div className="my-1 border-t"/>
{templates.map((t) => (<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
</SelectItem>))}
</SelectContent>
</Select>
</div>
{/* New-patient flow indicator */}
{pendingStartFlow === "new_patient" && (<div className="flex items-center gap-1 text-xs text-primary bg-primary/5 border border-primary/20 rounded px-2 py-0.5">
<UserPlus className="h-3 w-3"/>
New patient flow
</div>)}
{/* Reschedule flow indicator */}
{pendingStartFlow === "reschedule" && (<div className="flex items-center gap-1 text-xs text-primary bg-primary/5 border border-primary/20 rounded px-2 py-0.5">
<CalendarX className="h-3 w-3"/>
Reschedule flow
</div>)}
{/* AI handoff toggle */}
<div className="flex items-center gap-1.5 ml-auto">
<Bot className={`h-3.5 w-3.5 flex-shrink-0 ${handOffToAI ? "text-primary" : "text-muted-foreground"}`}/>
<span className="text-xs text-muted-foreground">Hand off to AI</span>
<Switch checked={handOffToAI} onCheckedChange={handleHandoffToggle} className="scale-75 origin-left"/>
</div>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4" data-testid="messages-container">
{isLoading ? (<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading messages...</p>
</div>) : communications.length === 0 ? (<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">No messages yet. Start the conversation!</p>
</div>) : (<>
{groupedMessages.map((group) => (<div key={group.date}>
<div className="flex items-center justify-center my-8">
<div className="px-4 py-1 bg-gray-100 rounded-full text-xs text-muted-foreground">
{getDateDivider(group.messages[0]?.createdAt)}
</div>
</div>
{group.messages.map((comm) => (<div key={comm.id} className={`flex mb-4 ${comm.direction === "outbound" ? "justify-end" : "justify-start"}`} data-testid={`message-${comm.id}`}>
<div className={`max-w-md ${comm.direction === "outbound" ? "ml-auto" : "mr-auto"}`}>
{comm.direction === "inbound" && (<div className="flex items-start gap-2">
<div className="h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center text-xs font-semibold flex-shrink-0">
{patient.firstName[0]}{patient.lastName[0]}
</div>
<div>
<div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
{comm.createdAt && formatMessageDate(comm.createdAt)}
</p>
</div>
</div>)}
{comm.direction === "outbound" && (<div>
<div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div>
<p className="text-xs text-muted-foreground mt-1 text-right">
{comm.createdAt && formatMessageDate(comm.createdAt)}
</p>
</div>)}
</div>
</div>))}
</div>))}
<div ref={messagesEndRef}/>
</>)}
</div>
{/* Input Area */}
<div className="p-4 border-t bg-gray-50">
<div className="flex items-center gap-2">
<Input value={messageText} onChange={(e) => setMessageText(e.target.value)} onKeyPress={handleKeyPress} placeholder="Type your message..." className="flex-1 rounded-full" disabled={sendMessageMutation.isPending} data-testid="input-message"/>
<Button onClick={handleSendMessage} disabled={!messageText.trim() || sendMessageMutation.isPending} size="icon" className="rounded-full h-10 w-10" data-testid="button-send">
<Send className="h-4 w-4"/>
</Button>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,157 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { MessageSquare, Send, Loader2, Save } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
const DEFAULT_TEMPLATES = {
appointment_reminder: {
name: "Appointment Reminder",
body: (firstName) => `Hi ${firstName}, this is your dental office. Reminder: You have an appointment scheduled. Please confirm or call us if you need to reschedule.`,
},
appointment_confirmation: {
name: "Appointment Confirmation",
body: (firstName) => `Hi ${firstName}, your appointment has been confirmed. We look forward to seeing you! If you have any questions, please call our office.`,
},
follow_up: {
name: "Follow-Up",
body: (firstName) => `Hi ${firstName}, thank you for visiting our dental office. How are you feeling after your treatment? Please let us know if you have any concerns.`,
},
payment_reminder: {
name: "Payment Reminder",
body: (firstName) => `Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`,
},
general: {
name: "General Message",
body: (firstName) => `Hi ${firstName}, this is your dental office. `,
},
custom: {
name: "Custom Message",
body: () => "",
},
};
const TEMPLATE_KEYS = Object.keys(DEFAULT_TEMPLATES);
function getDefaultBody(key, firstName) {
const t = DEFAULT_TEMPLATES[key];
if (!t)
return "";
return typeof t.body === "function" ? t.body(firstName) : t.body;
}
export function SmsTemplateDialog({ open, onOpenChange, patient, }) {
const [selectedKey, setSelectedKey] = useState("appointment_reminder");
const [messageText, setMessageText] = useState("");
const { toast } = useToast();
const { data: savedTemplates = {} } = useQuery({
queryKey: ["/api/twilio/templates"],
enabled: open,
});
// Resolve effective body for a given key (saved override or default)
const resolveBody = (key) => {
if (key === "custom")
return "";
if (savedTemplates[key]) {
// Replace placeholder first name with actual patient name
return savedTemplates[key].replace(/^Hi \w+,/, `Hi ${patient?.firstName ?? ""},`);
}
return getDefaultBody(key, patient?.firstName ?? "");
};
// When dialog opens or template changes, populate message
useEffect(() => {
if (open)
setMessageText(resolveBody(selectedKey));
}, [open, selectedKey, savedTemplates, patient?.firstName]);
const sendMutation = useMutation({
mutationFn: async (message) => {
return apiRequest("POST", "/api/twilio/send-sms", {
to: patient.phone,
message,
patientId: patient.id,
});
},
onSuccess: () => {
toast({ title: "SMS Sent", description: `Message sent to ${patient?.firstName} ${patient?.lastName}` });
onOpenChange(false);
setSelectedKey("appointment_reminder");
setMessageText("");
},
onError: (err) => {
toast({ title: "Failed to Send SMS", description: err.message || "Please check your Twilio configuration.", variant: "destructive" });
},
});
const updateMutation = useMutation({
mutationFn: async ({ key, body }) => {
return apiRequest("PUT", `/api/twilio/templates/${key}`, { body });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/twilio/templates"] });
toast({ title: "Template Updated", description: "This template will be used going forward." });
},
onError: (err) => {
toast({ title: "Failed to Update Template", description: err.message, variant: "destructive" });
},
});
const handleTemplateChange = (key) => {
setSelectedKey(key);
setMessageText(resolveBody(key));
};
return (<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5"/>
Send SMS to {patient?.firstName} {patient?.lastName}
</DialogTitle>
<DialogDescription>
Choose a template or write a custom message
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Message Template</Label>
<Select value={selectedKey} onValueChange={handleTemplateChange}>
<SelectTrigger data-testid="select-sms-template">
<SelectValue placeholder="Select a template"/>
</SelectTrigger>
<SelectContent>
{TEMPLATE_KEYS.map((key) => (<SelectItem key={key} value={key}>
{DEFAULT_TEMPLATES[key]?.name ?? key}
{savedTemplates[key] ? " ✎" : ""}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Message</Label>
{selectedKey !== "custom" && (<Button type="button" variant="outline" size="sm" className="h-7 text-xs gap-1" disabled={!messageText.trim() || updateMutation.isPending} onClick={() => updateMutation.mutate({ key: selectedKey, body: messageText })}>
{updateMutation.isPending ? (<Loader2 className="h-3 w-3 animate-spin"/>) : (<Save className="h-3 w-3"/>)}
{updateMutation.isPending ? "Saving..." : "Update Template"}
</Button>)}
</div>
<Textarea value={messageText} onChange={(e) => setMessageText(e.target.value)} placeholder="Type your message here..." rows={5} className="resize-none" data-testid="textarea-sms-message"/>
<p className="text-xs text-muted-foreground">
{patient?.phone
? `Will be sent to: ${patient.phone}`
: "No phone number available"}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => sendMutation.mutate(messageText)} disabled={!patient?.phone || !messageText.trim() || sendMutation.isPending || !patient} className="gap-2" data-testid="button-send-sms">
{sendMutation.isPending ? (<Loader2 className="h-4 w-4 animate-spin"/>) : (<Send className="h-4 w-4"/>)}
{sendMutation.isPending ? "Sending..." : "Send SMS"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>);
}

View File

@@ -0,0 +1,107 @@
import { useState, forwardRef, useImperativeHandle, useEffect, useRef, } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTitle, DialogDescription, DialogHeader, DialogContent, DialogFooter, } from "@/components/ui/dialog";
import { PatientForm } from "./patient-form";
import { X, Calendar } from "lucide-react";
import { useLocation } from "wouter";
export const AddPatientModal = forwardRef(function AddPatientModal(props, ref) {
const { open, onOpenChange, onSubmit, isLoading, patient, extractedInfo } = props;
const [formData, setFormData] = useState(null);
const isEditing = !!patient;
const [, navigate] = useLocation();
const [saveAndSchedule, setSaveAndSchedule] = useState(false);
const [saveAndClaim, setSaveAndClaim] = useState(false);
const patientFormRef = useRef(null); // Ref for PatientForm
// Set up the imperativeHandle to expose functionality to the parent component
useEffect(() => {
if (isEditing && patient) {
const { id, userId, createdAt, ...sanitized } = patient;
setFormData(sanitized); // Update the form data with the patient data for editing
}
else {
setFormData(null); // Reset form data when not editing
}
}, [isEditing, patient]);
useImperativeHandle(ref, () => ({
shouldSchedule: saveAndSchedule,
shouldClaim: saveAndClaim, // ✅ NEW
navigateToSchedule: (patientId) => {
navigate(`/appointments?newPatient=${patientId}`);
},
navigateToClaim: (patientId) => {
// ✅ NEW
navigate(`/claims?newPatient=${patientId}`);
},
}));
const handleFormSubmit = (data) => {
if (patient && patient.id) {
onSubmit({ ...data, id: patient.id });
}
else {
onSubmit(data);
}
};
const handleSaveAndSchedule = () => {
setSaveAndClaim(false); // ensure only one flag at a time
setSaveAndSchedule(true);
patientFormRef.current?.submit();
};
const handleSaveAndClaim = () => {
setSaveAndSchedule(false); // ensure only one flag at a time
setSaveAndClaim(true);
patientFormRef.current?.submit();
};
return (<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>
{isEditing ? "Edit Patient" : "Add New Patient"}
</DialogTitle>
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)}>
<X className="h-4 w-4"/>
</Button>
</div>
<DialogDescription>
{isEditing
? "Update patient information in the form below."
: "Fill out the patient information to add them to your records."}
</DialogDescription>
</DialogHeader>
<PatientForm ref={patientFormRef} patient={patient} extractedInfo={extractedInfo} onSubmit={handleFormSubmit}/>
<DialogFooter className="mt-6">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{!isEditing && (<Button variant="outline" className="gap-1" onClick={handleSaveAndClaim} disabled={isLoading}>
<Calendar className="h-4 w-4"/>
Save & Claim/PreAuth
</Button>)}
{!isEditing && (<Button variant="outline" className="gap-1" onClick={() => {
handleSaveAndSchedule();
}} disabled={isLoading}>
<Calendar className="h-4 w-4"/>
Save & Schedule
</Button>)}
<Button type="button" form="patient-form" onClick={() => {
if (patientFormRef.current) {
patientFormRef.current.submit();
}
}} disabled={isLoading}>
{isLoading
? patient
? "Updating..."
: "Saving..."
: patient
? "Update Patient"
: "Save Patient"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>);
});

View File

@@ -0,0 +1,265 @@
import React, { useEffect, useMemo, useState } from "react";
import { Dialog, DialogContent, DialogTitle, DialogDescription, } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import { apiRequest } from "@/lib/queryClient";
import LoadingScreen from "../ui/LoadingScreen";
import { useToast } from "@/hooks/use-toast";
import { useLocation } from "wouter";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
export function PatientFinancialsModal({ patientId, open, onOpenChange, }) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [limit, setLimit] = useState(50);
const [offset, setOffset] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [, navigate] = useLocation();
const { toast } = useToast();
// patient summary to show in header
const [patientName, setPatientName] = useState(null);
const [patientPID, setPatientPID] = useState(null);
useEffect(() => {
if (!open || !patientId)
return;
fetchPatient();
fetchRows();
}, [open, patientId, limit, offset]);
async function fetchPatient() {
try {
const res = await apiRequest("GET", `/api/patients/${patientId}`);
if (!res.ok) {
return;
}
const patient = await res.json();
setPatientName(`${patient.firstName} ${patient.lastName}`);
setPatientPID(patient.id);
}
catch (err) {
console.error("Failed to fetch patient", err);
}
}
async function fetchRows() {
if (!patientId)
return;
setLoading(true);
try {
const url = `/api/patients/${patientId}/financials?limit=${limit}&offset=${offset}`;
const res = await apiRequest("GET", url);
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || "Failed to load");
}
const data = await res.json();
setRows(data.rows || []);
setTotalCount(Number(data.totalCount || 0));
}
catch (err) {
console.error(err);
toast?.({
title: "Error",
description: err.message || "Failed to load financials",
variant: "destructive",
});
}
finally {
setLoading(false);
}
}
function gotoRow(r) {
const openInNewTab = (url) => {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
else {
// fallback for non-browser env (shouldn't happen in the client)
navigate(url);
}
};
const makePaymentUrl = (id) => `/payments?paymentId=${id}`;
if (r.linked_payment_id) {
openInNewTab(makePaymentUrl(r.linked_payment_id));
return;
}
if (r.type === "PAYMENT") {
openInNewTab(makePaymentUrl(r.id));
return;
}
}
const currentPage = Math.floor(offset / limit) + 1;
const totalPages = Math.max(1, Math.ceil(totalCount / limit));
function setPage(page) {
if (page < 1)
page = 1;
if (page > totalPages)
page = totalPages;
setOffset((page - 1) * limit);
}
const startItem = useMemo(() => Math.min(offset + 1, totalCount || 0), [offset, totalCount]);
const endItem = useMemo(() => Math.min(offset + limit, totalCount || 0), [offset, limit, totalCount]);
return (<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl w-[95%] p-0 overflow-hidden">
<div className="border-b px-6 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<DialogTitle className="text-lg">Financials</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{patientName ? (<>
<span className="font-medium">{patientName}</span>{" "}
{patientPID && (<span className="text-muted-foreground">
PID-{String(patientPID).padStart(4, "0")}
</span>)}
</>) : ("Claims, payments and balances for this patient.")}
</DialogDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Close
</Button>
</div>
</div>
</div>
<div className="px-6 py-4">
<div className="border rounded-md overflow-hidden">
<div className="max-h-[56vh] overflow-auto">
<Table className="min-w-full">
<TableHeader className="sticky top-0 bg-white z-10">
<TableRow>
<TableHead className="w-24">Type</TableHead>
<TableHead className="w-36">Date</TableHead>
<TableHead>Procedures Codes</TableHead>
<TableHead className="w-28">Tooth Number</TableHead>
<TableHead className="text-right w-28">Billed</TableHead>
<TableHead className="text-right w-28">Paid</TableHead>
<TableHead className="text-right w-28">Adjusted</TableHead>
<TableHead className="text-right w-28">Total Due</TableHead>
<TableHead className="w-28">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (<TableRow>
<TableCell colSpan={9} className="text-center py-12">
<LoadingScreen />
</TableCell>
</TableRow>) : rows.length === 0 ? (<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
No records found.
</TableCell>
</TableRow>) : (rows.map((r) => {
const billed = Number(r.total_billed ?? 0);
const paid = Number(r.total_paid ?? 0);
const adjusted = Number(r.total_adjusted ?? 0);
const totalDue = Number(r.total_due ?? 0);
const serviceLines = r.service_lines || [];
const procedureCodes = serviceLines.length > 0
? serviceLines
.map((sl) => sl.procedureCode)
.filter(Boolean)
.join(", ")
: r.linked_payment_id
? "No Codes Given"
: "-";
const toothNumbers = serviceLines.length > 0
? serviceLines
.map((sl) => sl.toothNumber ? String(sl.toothNumber) : "-")
.join(", ")
: "-";
return (<TableRow key={`${r.type}-${r.id}`} className="cursor-pointer hover:bg-gray-50" onClick={() => gotoRow(r)}>
<TableCell className="font-medium">
{r.type}
</TableCell>
<TableCell>
{r.date
? new Date(r.date).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{procedureCodes}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{toothNumbers}
</TableCell>
<TableCell className="text-right">
{billed.toFixed(2)}
</TableCell>
<TableCell className="text-right">
{paid.toFixed(2)}
</TableCell>
<TableCell className="text-right">
{adjusted.toFixed(2)}
</TableCell>
<TableCell className={`text-right ${totalDue > 0 ? "text-red-600" : "text-green-600"}`}>
{totalDue.toFixed(2)}
</TableCell>
<TableCell>{r.status ?? "-"}</TableCell>
</TableRow>);
}))}
</TableBody>
</Table>
</div>
</div>
</div>
<div className="border-t px-6 py-3 bg-white">
<div className="flex flex-col md:flex-row items-center md:items-center justify-between gap-3">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">Rows:</label>
<select value={limit} onChange={(e) => {
setLimit(Number(e.target.value));
setOffset(0);
}} className="border rounded px-2 py-1 text-sm">
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium">{startItem}</span>
<span className="font-medium">{endItem}</span> of{" "}
<span className="font-medium">{totalCount}</span>
</div>
</div>
<div className="flex items-center gap-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setPage(currentPage - 1);
}} className={currentPage === 1
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (<PaginationItem key={idx}>
{page === "..." ? (<span className="px-2 text-gray-500"></span>) : (<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setPage(Number(page));
}} isActive={currentPage === page}>
{page}
</PaginationLink>)}
</PaginationItem>))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setPage(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</div>
</DialogContent>
</Dialog>);
}

View File

@@ -0,0 +1,181 @@
import { useState } from "react";
import { CalendarIcon, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogClose, } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { Calendar } from "@/components/ui/calendar";
export function PatientSearch({ onSearch, onClearSearch, isSearchActive, }) {
const [dobOpen, setDobOpen] = useState(false);
const [advanceDobOpen, setAdvanceDobOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [searchBy, setSearchBy] = useState("name");
const [showAdvanced, setShowAdvanced] = useState(false);
const [advancedCriteria, setAdvancedCriteria] = useState({
searchTerm: "",
searchBy: "name",
});
const handleSearch = () => {
onSearch({
searchTerm,
searchBy,
});
};
const handleClear = () => {
setSearchTerm("");
setSearchBy("all");
onClearSearch();
};
const handleAdvancedSearch = () => {
onSearch(advancedCriteria);
setShowAdvanced(false);
};
const updateAdvancedCriteria = (field, value) => {
setAdvancedCriteria({
...advancedCriteria,
[field]: value,
});
};
return (<div className="w-full pt-8 pb-4 px-4">
<div className="flex gap-2 mb-4">
<div className="relative flex-1">
{searchBy === "dob" ? (<Popover open={dobOpen} onOpenChange={setDobOpen}>
<PopoverTrigger asChild>
<Button variant="outline" onKeyDown={(e) => {
if (e.key === "Enter")
handleSearch();
}} className={cn("w-full pl-3 pr-20 text-left font-normal", !searchTerm && "text-muted-foreground")}>
{searchTerm ? (format(new Date(searchTerm), "PPP")) : (<span>Pick a date</span>)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50"/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4">
<Calendar mode="single" selected={searchTerm ? new Date(searchTerm) : undefined} onSelect={(date) => {
if (date) {
const formattedDate = format(date, "yyyy-MM-dd");
setSearchTerm(String(formattedDate));
setDobOpen(false);
}
}} disabled={(date) => date > new Date()}/>
</PopoverContent>
</Popover>) : (<Input placeholder="Search patients..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pr-10" onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}/>)}
{searchTerm && (<button className="absolute right-10 top-3 text-gray-400 hover:text-gray-600" onClick={() => {
setSearchTerm("");
if (isSearchActive)
onClearSearch();
}}>
<X size={16}/>
</button>)}
<button className="absolute right-3 top-3 text-gray-400 hover:text-gray-600" onClick={handleSearch}>
<Search size={16}/>
</button>
</div>
<Select value={searchBy} onValueChange={(value) => {
setSearchBy(value);
setSearchTerm("");
}}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Search by..."/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Fields</SelectItem>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="insuranceId">InsuranceId</SelectItem>
<SelectItem value="gender">Gender</SelectItem>
<SelectItem value="dob">DOB</SelectItem>
</SelectContent>
</Select>
<Dialog open={showAdvanced} onOpenChange={setShowAdvanced}>
<DialogTrigger asChild>
<Button variant="outline">Advanced</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Advanced Search</DialogTitle>
<DialogDescription>
Search for patients using multiple criteria
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<label className="text-right text-sm font-medium">
Search by
</label>
<Select value={advancedCriteria.searchBy} onValueChange={(value) => {
setAdvancedCriteria((prev) => ({
...prev,
searchBy: value,
searchTerm: "",
}));
}}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Name"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Fields</SelectItem>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="insuranceId">InsuranceId</SelectItem>
<SelectItem value="gender">Gender</SelectItem>
<SelectItem value="dob">DOB</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<label className="text-right text-sm font-medium">
Search term
</label>
{advancedCriteria.searchBy === "dob" ? (<Popover open={advanceDobOpen} onOpenChange={setAdvanceDobOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" onKeyDown={(e) => {
if (e.key === "Enter")
handleSearch();
}} className={cn("col-span-3 text-left font-normal", !advancedCriteria.searchTerm &&
"text-muted-foreground")}>
{advancedCriteria.searchTerm ? (format(new Date(advancedCriteria.searchTerm), "PPP")) : (<span>Pick a date</span>)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50"/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4">
<Calendar mode="single" selected={advancedCriteria.searchTerm
? new Date(advancedCriteria.searchTerm)
: undefined} onSelect={(date) => {
if (date) {
const formattedDate = format(date, "yyyy-MM-dd");
updateAdvancedCriteria("searchTerm", String(formattedDate));
setAdvanceDobOpen(false);
}
}} disabled={(date) => date > new Date()}/>
</PopoverContent>
</Popover>) : (<Input className="col-span-3" value={advancedCriteria.searchTerm} onChange={(e) => updateAdvancedCriteria("searchTerm", e.target.value)} placeholder="Enter search term..."/>)}
</div>
</div>
<div className="flex justify-end gap-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleAdvancedSearch}>Search</Button>
</div>
</DialogContent>
</Dialog>
{isSearchActive && (<Button variant="outline" onClick={handleClear}>
Clear
</Button>)}
</div>
</div>);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,811 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { formatDateToHumanReadable, formatLocalDate, parseLocalDate, } from "@/utils/dateUtils";
import React, { useEffect, useState } from "react";
import { paymentMethodOptions, paymentStatusArray, paymentMethodArray, } from "@repo/db/types";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { toast } from "@/hooks/use-toast";
import { X } from "lucide-react";
import { DateInput } from "@/components/ui/dateInput";
import { apiRequest } from "@/lib/queryClient";
import { useMutation, useQuery } from "@tanstack/react-query";
export default function PaymentEditModal({ isOpen, onOpenChange, onClose, onEditServiceLine, isUpdatingServiceLine: propUpdatingServiceLine, onUpdateStatus, isUpdatingStatus: propUpdatingStatus, payment: paymentProp, paymentId: paymentIdProp, }) {
// Local payment state: prefer prop but fetch if paymentId is provided
const [payment, setPayment] = useState(paymentProp ?? null);
const [loadingPayment, setLoadingPayment] = useState(false);
// Local update states (used if parent didn't provide flags)
const [localUpdatingServiceLine, setLocalUpdatingServiceLine] = useState(false);
const [localUpdatingStatus, setLocalUpdatingStatus] = useState(false);
// derived flags - prefer parent's flags if provided
const isUpdatingServiceLine = propUpdatingServiceLine ?? localUpdatingServiceLine;
const isUpdatingStatus = propUpdatingStatus ?? localUpdatingStatus;
// UI state (kept from your original)
const [expandedLineId, setExpandedLineId] = useState(null);
const [paymentStatus, setPaymentStatus] = useState((paymentProp ?? null)?.status ?? "PENDING");
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState((paymentProp ?? null)?.npiProviderId ?? null);
const [isUpdatingProvider, setIsUpdatingProvider] = useState(false);
// Fetch all NPI providers
const { data: npiProviders = [] } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
return res.json();
},
});
// Default to Mary Scannell (or first provider) if no provider set yet
useEffect(() => {
if (!npiProviders.length)
return;
if (selectedNpiProviderId !== null)
return;
const mary = npiProviders.find((p) => p.providerName.toLowerCase().includes("mary scannell"));
const fallback = mary ?? npiProviders[0];
if (fallback)
setSelectedNpiProviderId(fallback.id);
}, [npiProviders]);
// Sync provider when payment changes (e.g. fetched from API)
useEffect(() => {
if (payment?.npiProviderId !== undefined) {
setSelectedNpiProviderId(payment.npiProviderId ?? null);
}
}, [payment?.id]);
const handleUpdateProvider = async () => {
if (!payment)
return;
setIsUpdatingProvider(true);
try {
const res = await apiRequest("PATCH", `/api/payments/${payment.id}/provider`, {
npiProviderId: selectedNpiProviderId,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || "Failed to update provider");
}
toast({ title: "Success", description: "Provider updated successfully." });
await refetchPayment(payment.id);
}
catch (err) {
toast({ title: "Error", description: err?.message ?? "Failed to update provider.", variant: "destructive" });
}
finally {
setIsUpdatingProvider(false);
}
};
const [formState, setFormState] = useState(() => {
return {
serviceLineId: 0,
transactionId: "",
paidAmount: 0,
adjustedAmount: 0,
method: paymentMethodOptions.CHECK,
receivedDate: formatLocalDate(new Date()),
payerName: "",
notes: "",
};
});
// Sync when parent passes a payment object or paymentId changes
useEffect(() => {
// if parent gave a full payment object, use it immediately
if (paymentProp) {
setPayment(paymentProp);
setPaymentStatus(paymentProp.status);
}
}, [paymentProp]);
// Fetch payment when modal opens and we only have paymentId (or payment prop not supplied)
useEffect(() => {
if (!isOpen)
return;
// if payment prop is already available, no need to fetch
if (paymentProp)
return;
const id = paymentIdProp ?? payment?.id;
if (!id)
return;
let cancelled = false;
(async () => {
setLoadingPayment(true);
try {
const res = await apiRequest("GET", `/api/payments/${id}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message ?? `Failed to fetch payment ${id}`);
}
const data = await res.json();
if (!cancelled) {
setPayment(data);
setPaymentStatus(data.status);
}
}
catch (err) {
console.error("Failed to fetch payment:", err);
toast({
title: "Error",
description: err?.message ?? "Failed to load payment.",
variant: "destructive",
});
}
finally {
if (!cancelled)
setLoadingPayment(false);
}
})();
return () => {
cancelled = true;
};
}, [isOpen, paymentIdProp]);
// convenience: get service lines from claim or payment
const serviceLines = payment?.claim?.serviceLines ?? payment?.serviceLines ?? [];
// small helper to refresh current payment (used after internal writes)
async function refetchPayment(id) {
const pid = id ?? payment?.id ?? paymentIdProp;
if (!pid)
return;
setLoadingPayment(true);
try {
const res = await apiRequest("GET", `/api/payments/${pid}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message ?? `Failed to fetch payment ${pid}`);
}
const data = await res.json();
setPayment(data);
setPaymentStatus(data.status);
}
catch (err) {
console.error("Failed to refetch payment:", err);
toast({
title: "Error",
description: err?.message ?? "Failed to refresh payment.",
variant: "destructive",
});
}
finally {
setLoadingPayment(false);
}
}
// Internal save (fallback) — used only when parent didn't provide onEditServiceLine
const internalUpdatePaymentMutation = useMutation({
mutationFn: async (data) => {
const response = await apiRequest("PUT", `/api/payments/${data.paymentId}`, {
data: data,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update Payment");
}
return response.json();
},
onSuccess: async (updated, { paymentId }) => {
toast({
title: "Success",
description: "Payment updated successfully!",
});
await refetchPayment(paymentId);
},
onError: (error) => {
toast({
title: "Error",
description: `Update failed: ${error.message}`,
variant: "destructive",
});
},
});
const internalUpdatePaymentStatusMutation = useMutation({
mutationFn: async ({ paymentId, status, }) => {
const response = await apiRequest("PATCH", `/api/payments/${paymentId}/status`, {
data: { status },
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update payment status");
}
return response.json();
},
onSuccess: async (updated, { paymentId }) => {
toast({
title: "Success",
description: "Payment Status updated successfully!",
});
// Fetch updated payment and set into local state
await refetchPayment(paymentId);
},
onError: (error) => {
toast({
title: "Error",
description: `Status update failed: ${error.message}`,
variant: "destructive",
});
},
});
// Keep your existing handlers but route to either parent callback or internal functions
const handleEditServiceLine = async (payload) => {
if (onEditServiceLine) {
await onEditServiceLine(payload);
}
else {
// fallback to internal API call
setLocalUpdatingServiceLine(true);
await internalUpdatePaymentMutation.mutateAsync(payload);
setLocalUpdatingServiceLine(false);
}
};
const handleUpdateStatus = async (paymentId, status) => {
if (onUpdateStatus) {
await onUpdateStatus(paymentId, status);
}
else {
setLocalUpdatingStatus(true);
await internalUpdatePaymentStatusMutation.mutateAsync({
paymentId,
status,
});
setLocalUpdatingStatus(false);
}
};
const handleEditServiceLineClick = (lineId) => {
if (expandedLineId === lineId) {
// Closing current line
setExpandedLineId(null);
return;
}
// Find line data
const line = serviceLines.find((sl) => sl.id === lineId);
if (!line)
return;
// updating form to show its data, while expanding.
setFormState({
serviceLineId: line.id,
transactionId: "",
paidAmount: Number(line.totalDue) > 0 ? Number(line.totalDue) : 0,
adjustedAmount: 0,
method: paymentMethodOptions.CHECK,
receivedDate: formatLocalDate(new Date()),
payerName: "",
notes: "",
});
setExpandedLineId(lineId);
};
const updateField = (field, value) => {
setFormState((prev) => ({
...prev,
[field]: value,
}));
};
const handleSavePayment = async () => {
if (!payment) {
toast({
title: "Error",
description: "Payment not loaded.",
variant: "destructive",
});
return;
}
if (!formState.serviceLineId) {
toast({
title: "Error",
description: "No service line selected.",
variant: "destructive",
});
return;
}
const paidAmount = Number(formState.paidAmount) || 0;
const adjustedAmount = Number(formState.adjustedAmount) || 0;
if (paidAmount < 0 || adjustedAmount < 0) {
toast({
title: "Invalid Amount",
description: "Amounts cannot be negative.",
variant: "destructive",
});
return;
}
if (paidAmount === 0 && adjustedAmount === 0) {
toast({
title: "Invalid Amount",
description: "Either paid or adjusted amount must be greater than zero.",
variant: "destructive",
});
return;
}
const line = serviceLines.find((sl) => sl.id === formState.serviceLineId);
if (!line) {
toast({
title: "Error",
description: "Selected service line not found.",
variant: "destructive",
});
return;
}
const dueAmount = Number(line.totalDue);
if (paidAmount > dueAmount) {
toast({
title: "Invalid Payment",
description: `Paid amount ($${paidAmount.toFixed(2)}) cannot exceed due amount ($${dueAmount.toFixed(2)}).`,
variant: "destructive",
});
return;
}
const payload = {
paymentId: payment.id,
serviceLineTransactions: [
{
serviceLineId: formState.serviceLineId,
transactionId: formState.transactionId || undefined,
paidAmount: Number(formState.paidAmount),
adjustedAmount: Number(formState.adjustedAmount) || 0,
method: formState.method,
receivedDate: parseLocalDate(formState.receivedDate),
payerName: formState.payerName?.trim() || undefined,
notes: formState.notes?.trim() || undefined,
},
],
};
try {
await handleEditServiceLine(payload);
toast({
title: "Success",
description: "Payment Transaction added successfully.",
});
setExpandedLineId(null);
}
catch (err) {
console.error(err);
toast({ title: "Error", description: "Failed to save payment." });
}
};
const handlePayFullDue = async (line) => {
if (!line || !payment) {
toast({
title: "Error",
description: "Service line or payment data missing.",
variant: "destructive",
});
return;
}
const dueAmount = Number(line.totalDue);
if (isNaN(dueAmount) || dueAmount <= 0) {
toast({
title: "No Due",
description: "This service line has no outstanding balance.",
variant: "destructive",
});
return;
}
const payload = {
paymentId: payment.id,
serviceLineTransactions: [
{
serviceLineId: line.id,
paidAmount: dueAmount,
adjustedAmount: 0,
method: paymentMethodOptions.CHECK, // Maybe make dynamic later
receivedDate: new Date(),
},
],
};
try {
await handleEditServiceLine(payload);
toast({
title: "Success",
description: `Full due amount ($${dueAmount.toFixed(2)}) paid for ${line.procedureCode}`,
});
}
catch (err) {
console.error(err);
toast({
title: "Error",
description: "Failed to update payment.",
variant: "destructive",
});
}
};
if (!payment) {
return (<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<div className="p-8 text-center">
{loadingPayment ? "Loading…" : "No payment selected"}
</div>
</DialogContent>
</Dialog>);
}
return (<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<div className="relative">
<DialogHeader>
<DialogTitle>Edit Payment</DialogTitle>
<DialogDescription>
View and manage payments applied to service lines.
</DialogDescription>
</DialogHeader>
{/* Close button in top-right */}
<Button variant="ghost" size="icon" onClick={onClose} className="absolute right-0 top-0">
<X className="h-4 w-4"/>
</Button>
</div>
<div className="space-y-6">
{/* Claim + Patient Info */}
<div className="space-y-2 border-b border-gray-200 pb-4">
<h3 className="text-2xl font-bold text-gray-900">
{payment.claim?.patientName ??
(`${payment.patient?.firstName ?? ""} ${payment.patient?.lastName ?? ""}`.trim() ||
"Unknown Patient")}
</h3>
<div className="flex flex-wrap items-center gap-3 text-sm">
{payment.claimId ? (<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
Claim #{payment.claimId.toString().padStart(4, "0")}
</span>) : payment.notes?.startsWith("PDF import") ? (<span className="bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full font-medium">
PDF Import
</span>) : (<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
OCR Imported Payment
</span>)}
<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
Service Date:{" "}
{payment.claim?.serviceDate
? formatDateToHumanReadable(payment.claim.serviceDate)
: serviceLines.length > 0
? formatDateToHumanReadable(serviceLines[0]?.procedureDate)
: formatDateToHumanReadable(payment.createdAt)}
</span>
{payment.icn ? (<span className="bg-gray-100 text-gray-800 px-2 py-0.5 rounded-full font-medium">
ICN : {payment.icn}
</span>) : null}
</div>
</div>
{/* Payment Summary + Metadata */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Payment Info */}
<div>
<h4 className="font-semibold text-gray-900">Payment Info</h4>
<div className="mt-2 space-y-1 text-sm">
<p>
<span className="text-gray-500">Total Billed:</span>{" "}
<span className="font-medium">
${Number(payment.totalBilled || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Total Paid:</span>{" "}
<span className="font-medium text-green-600">
${Number(payment.totalPaid || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Total Due:</span>{" "}
<span className="font-medium text-red-600">
${Number(payment.totalDue || 0).toFixed(2)}
</span>
</p>
{/* Status Selector */}
<div className="pt-3">
<label className="block text-sm text-gray-600 mb-1">
Payment Status
</label>
<Select value={paymentStatus} onValueChange={(value) => setPaymentStatus(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{paymentStatusArray.map((status) => (<SelectItem key={status} value={status}>
{status}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<Button size="sm" disabled={isUpdatingStatus} onClick={() => payment && handleUpdateStatus(payment.id, paymentStatus)}>
{isUpdatingStatus ? "Updating..." : "Update Status"}
</Button>
{/* Provider Selector */}
<div className="pt-3">
<label className="block text-sm text-gray-600 mb-1">
Rendering Provider
</label>
<Select value={selectedNpiProviderId?.toString() ?? ""} onValueChange={(val) => setSelectedNpiProviderId(Number(val))}>
<SelectTrigger>
<SelectValue placeholder="Select Provider"/>
</SelectTrigger>
<SelectContent>
{npiProviders.map((p) => (<SelectItem key={p.id} value={p.id.toString()}>
{p.npiNumber} {p.providerName}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<Button size="sm" disabled={isUpdatingProvider} onClick={handleUpdateProvider}>
{isUpdatingProvider ? "Updating..." : "Update Provider"}
</Button>
</div>
</div>
{/* Metadata */}
<div>
<h4 className="font-semibold text-gray-900">Metadata</h4>
<div className="mt-2 space-y-1 text-sm">
<p>
<span className="text-gray-500">Created At:</span>{" "}
{payment.createdAt
? formatDateToHumanReadable(payment.createdAt)
: "N/A"}
</p>
<p>
<span className="text-gray-500">Last Updated At:</span>{" "}
{payment.updatedAt
? formatDateToHumanReadable(payment.updatedAt)
: "N/A"}
</p>
{payment.commissionBatchItems?.length > 0 ? (<div className="pt-2">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-100 text-purple-800">
Commissioned
</span>
<p className="mt-1 text-xs text-gray-500">
Paid as commission on{" "}
{formatDateToHumanReadable(payment.commissionBatchItems[0].commissionBatch.createdAt)}
</p>
</div>) : (<p className="pt-1">
<span className="text-gray-400 text-xs">Not yet commissioned</span>
</p>)}
</div>
</div>
</div>
{/* Service Lines Payments */}
<div>
<h4 className="font-medium text-gray-900 pt-4">Service Lines</h4>
<div className="mt-3 space-y-4">
{serviceLines.length > 0 ? (serviceLines.map((line) => {
const isExpanded = expandedLineId === line.id;
return (<div key={line.id} className="border border-gray-200 p-4 rounded-xl bg-white shadow-sm hover:shadow-md transition-shadow duration-200">
{/* Top Info */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<p>
<span className="text-gray-500">Procedure Code:</span>{" "}
<span className="font-medium">
{line.procedureCode}
</span>
</p>
{line.icn && (<p>
<span className="text-gray-500">ICN:</span>{" "}
<span className="font-medium font-mono text-xs">
{line.icn}
</span>
</p>)}
{line.paidCode && line.paidCode !== line.procedureCode && (<p>
<span className="text-gray-500">Paid Code:</span>{" "}
<span className="font-medium">{line.paidCode}</span>
</p>)}
{line.allowedAmount != null && (<p>
<span className="text-gray-500">Allowed:</span>{" "}
<span className="font-medium text-blue-600">
${Number(line.allowedAmount).toFixed(2)}
</span>
</p>)}
{line.quad && (<p>
<span className="text-gray-500">Quad:</span>{" "}
<span className="font-medium">
{line.quad}
</span>
</p>)}
{line.arch && (<p>
<span className="text-gray-500">Arch:</span>{" "}
<span className="font-medium">
{line.arch}
</span>
</p>)}
{line.toothNumber && (<p>
<span className="text-gray-500">Tooth Number:</span>{" "}
<span className="font-medium">
{line.toothNumber}
</span>
</p>)}
{line.toothSurface && (<p>
<span className="text-gray-500">Surface:</span>{" "}
<span className="font-medium">
{line.toothSurface}
</span>
</p>)}
<p>
<span className="text-gray-500">Billed:</span>{" "}
<span className="font-semibold">
${Number(line.totalBilled || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Paid:</span>{" "}
<span className="font-semibold text-green-600">
${Number(line.totalPaid || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Adjusted:</span>{" "}
<span className="font-semibold text-yellow-600">
${Number(line.totalAdjusted || 0).toFixed(2)}
</span>
</p>
<p>
<span className="text-gray-500">Due:</span>{" "}
<span className="font-semibold text-red-600">
${Number(line.totalDue || 0).toFixed(2)}
</span>
</p>
</div>
{/* Action Buttons */}
<div className="pt-4 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleEditServiceLineClick(line.id)}>
{isExpanded ? "Cancel" : "Pay Partially"}
</Button>
<Button variant="default" size="sm" onClick={() => handlePayFullDue(line)}>
Pay Full Due
</Button>
</div>
{/* Expanded Partial Payment Form */}
{isExpanded && (<div className="mt-4 p-4 border-t border-gray-200 bg-gray-50 rounded-lg space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">
Paid Amount
</label>
<Input type="number" step="0.01" placeholder="Paid Amount" defaultValue={formState.paidAmount} onChange={(e) => updateField("paidAmount", parseFloat(e.target.value))}/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">
Adjusted Amount
</label>
<Input type="number" step="0.01" placeholder="Adjusted Amount" defaultValue={formState.adjustedAmount} onChange={(e) => updateField("adjustedAmount", parseFloat(e.target.value))}/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">
Payment Method
</label>
<Select value={formState.method} onValueChange={(value) => setFormState((prev) => ({
...prev,
method: value,
}))}>
<SelectTrigger>
<SelectValue placeholder="Select a payment method"/>
</SelectTrigger>
<SelectContent>
{paymentMethodArray.map((methodOption) => (<SelectItem key={methodOption} value={methodOption}>
{methodOption}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<DateInput label="Received Date" value={formState.receivedDate
? parseLocalDate(formState.receivedDate)
: null} onChange={(date) => {
if (date) {
const localDate = formatLocalDate(date);
updateField("receivedDate", localDate);
}
else {
updateField("receivedDate", null);
}
}} disableFuture/>
<div className="space-y-1">
<label className="text-sm font-medium">
Payer Name
</label>
<Input type="text" placeholder="Payer Name" value={formState.payerName} onChange={(e) => updateField("payerName", e.target.value)}/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Notes</label>
<Input type="text" placeholder="Notes" onChange={(e) => updateField("notes", e.target.value)}/>
</div>
<Button size="sm" disabled={isUpdatingServiceLine} onClick={() => handleSavePayment()}>
{isUpdatingStatus ? "Updating..." : "Update"}
</Button>
</div>)}
</div>);
})) : (<p className="text-gray-500">No service lines available.</p>)}
</div>
</div>
{/* Transactions Overview */}
<div>
<h4 className="font-medium text-gray-900 pt-6">All Transactions</h4>
<div className="mt-4 space-y-3">
{payment.serviceLineTransactions.length > 0 ? (payment.serviceLineTransactions.map((tx) => (<div key={tx.id} className="border border-gray-200 p-4 rounded-xl bg-white shadow-sm hover:shadow-md transition-shadow duration-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
{/* Transaction ID */}
{tx.id && (<p>
<span className="text-gray-500">Transaction ID:</span>{" "}
<span className="font-medium">{tx.id}</span>
</p>)}
{/* Procedure Code */}
{tx.serviceLine?.procedureCode && (<p>
<span className="text-gray-500">Procedure Code:</span>{" "}
<span className="font-medium">
{tx.serviceLine.procedureCode}
</span>
</p>)}
{/* Tooth Number */}
{tx.serviceLine?.toothNumber && (<p>
<span className="text-gray-500">Tooth Number:</span>{" "}
<span className="font-medium">
{tx.serviceLine.toothNumber}
</span>
</p>)}
{/* Tooth Surface */}
{tx.serviceLine?.toothSurface && (<p>
<span className="text-gray-500">Surface:</span>{" "}
<span className="font-medium">
{tx.serviceLine.toothSurface}
</span>
</p>)}
{/* Paid Amount */}
<p>
<span className="text-gray-500">Paid Amount:</span>{" "}
<span className="font-semibold text-green-600">
${Number(tx.paidAmount).toFixed(2)}
</span>
</p>
{/* Adjusted Amount */}
{Number(tx.adjustedAmount) > 0 && (<p>
<span className="text-gray-500">
Adjusted Amount:
</span>{" "}
<span className="font-semibold text-yellow-600">
${Number(tx.adjustedAmount).toFixed(2)}
</span>
</p>)}
{/* Date */}
<p>
<span className="text-gray-500">Date:</span>{" "}
<span>
{formatDateToHumanReadable(tx.receivedDate)}
</span>
</p>
{/* Method */}
<p>
<span className="text-gray-500">Method:</span>{" "}
<span className="capitalize">{tx.method}</span>
</p>
{/* Payer Name */}
{tx.payerName && tx.payerName.trim() !== "" && (<p className="md:col-span-2">
<span className="text-gray-500">Payer Name:</span>{" "}
<span>{tx.payerName}</span>
</p>)}
{/* Notes */}
{tx.notes && tx.notes.trim() !== "" && (<p className="md:col-span-2">
<span className="text-gray-500">Notes:</span>{" "}
<span>{tx.notes}</span>
</p>)}
</div>
</div>))) : (<p className="text-gray-500">No transactions recorded.</p>)}
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-2 pt-6">
<Button variant="default" onClick={onClose}>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>);
}

View File

@@ -0,0 +1,318 @@
// PaymentOCRBlock.tsx
import * as React from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Image as ImageIcon, X, Plus } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { useReactTable, getCoreRowModel, flexRender, } from "@tanstack/react-table";
import { QK_PAYMENTS_RECENT_BASE } from "@/components/payments/payments-recent-table";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import { MultipleFileUploadZone, } from "../file-upload/multiple-file-upload-zone";
export default function PaymentOCRBlock() {
//Config
const MAX_FILES = 10;
const ACCEPTED_FILE_TYPES = "image/jpeg,image/jpg,image/png,image/webp";
const TITLE = "Payment Document OCR";
const DESCRIPTION = "You can upload up to 10 files. Allowed types: JPG, PNG, WEBP.";
// FILE/ZONE state
const uploadZoneRef = React.useRef(null);
const [filesForUI, setFilesForUI] = React.useState([]); // reactive UI only
const [isUploading, setIsUploading] = React.useState(false); // forwarded to zone
const [isExtracting, setIsExtracting] = React.useState(false);
// extracted rows shown only inside modal
const [rows, setRows] = React.useState([]);
const [modalColumns, setModalColumns] = React.useState([]);
const [showModal, setShowModal] = React.useState(false);
const [error, setError] = React.useState(null);
//Mutation
const extractPaymentOCR = useMutation({
mutationFn: async (files) => {
const formData = new FormData();
files.forEach((file) => formData.append("files", file, file.name));
const res = await apiRequest("POST", "/api/payment-ocr/extract", formData);
if (!res.ok)
throw new Error("Failed to extract payment data");
const data = (await res.json());
return Array.isArray(data) ? data : data.rows;
},
onSuccess: (data) => {
// Remove unwanted keys before using the data
const cleaned = data.map((row) => {
const { ["Extraction Success"]: _, ["Source File"]: __, ...rest } = row;
return rest;
});
const withIds = cleaned.map((r, i) => ({ ...r, __id: i }));
setRows(withIds);
const allKeys = Array.from(cleaned.reduce((acc, row) => {
Object.keys(row).forEach((k) => acc.add(k));
return acc;
}, new Set()));
setModalColumns(allKeys);
setIsExtracting(false);
setShowModal(true);
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to extract payment data: ${error.message}`,
variant: "destructive",
});
setIsExtracting(false);
},
});
// ---- handlers (all in this file) -----------------------------------------
// Called by zone when its internal list changes (keeps parent UI reactive)
const handleZoneFilesChange = React.useCallback((files) => {
setFilesForUI(files);
setError(null);
}, []);
// Remove a single file by asking the zone to remove it (zone exposes removeFile)
const removeUploadedFile = React.useCallback((index) => {
uploadZoneRef.current?.removeFile(index);
// zone will call onFilesChange and update filesForUI automatically
}, []);
// Extract: read files from zone via ref and call mutation
const handleExtract = () => {
const files = uploadZoneRef.current?.getFiles() ?? [];
if (!files.length) {
setError("Please upload at least one file to extract.");
return;
}
setIsExtracting(true);
extractPaymentOCR.mutate(files);
};
const handleSave = async () => {
try {
const skipped = [];
const payload = rows
.map((row, idx) => {
const patientName = row["Patient Name"];
const patientId = row["Patient ID"];
const procedureCode = row["CDT Code"];
if (!patientName || !patientId || !procedureCode) {
skipped.push(`Row ${idx + 1} (missing name/id/procedureCode)`);
return null;
}
return {
patientName,
insuranceId: patientId,
icn: row["ICN"] ?? null,
procedureCode: row["CDT Code"],
toothNumber: row["Tooth"] ?? null,
toothSurface: row["Surface"] ?? null,
procedureDate: row["Date SVC"] ?? null,
totalBilled: Number(row["Billed Amount"] ?? 0),
totalAllowed: Number(row["Allowed Amount"] ?? 0),
totalPaid: Number(row["Paid Amount"] ?? 0),
sourceFile: row["Source File"] ?? null,
};
})
.filter((r) => r !== null);
if (skipped.length > 0) {
toast({
title: "Some rows skipped, because of either no patient Name or MemberId given.",
description: skipped.join(", "),
variant: "destructive",
});
}
if (payload.length === 0) {
toast({
title: "Error",
description: "No valid rows to save",
variant: "destructive",
});
return;
}
const res = await apiRequest("POST", "/api/payments/full-ocr-import", {
rows: payload,
});
if (!res.ok)
throw new Error("Failed to save OCR payments");
toast({ title: "Saved", description: "OCR rows saved successfully" });
// 🔄 REFRESH both tables (all pages/filters)
await Promise.all([
queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE }), // all recent payments
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }), // recent patients list
]);
// ✅ CLEAR UI: reset zone + modal + rows
uploadZoneRef.current?.reset();
setFilesForUI([]);
setRows([]);
setModalColumns([]);
setError(null);
setIsExtracting(false);
setShowModal(false);
}
catch (err) {
toast({
title: "Error",
description: err.message,
variant: "destructive",
});
}
};
return (<div className="mb-8">
<Card>
<CardHeader>
<CardTitle>{TITLE}</CardTitle>
<CardDescription>{DESCRIPTION}</CardDescription>
</CardHeader>
<CardContent>
{/* Upload block */}
<div className="bg-gray-100 p-4 rounded-md space-y-4">
<MultipleFileUploadZone ref={uploadZoneRef} isUploading={isUploading} acceptedFileTypes={ACCEPTED_FILE_TYPES} maxFiles={MAX_FILES} onFilesChange={handleZoneFilesChange} // reactive UI only
/>
{/* Show list of files received from the upload zone (UI only) */}
{filesForUI.length > 0 && (<div>
<p className="text-sm text-gray-600 mb-2">
Uploaded ({filesForUI.length}/{MAX_FILES})
</p>
<ul className="space-y-2 max-h-48 overflow-auto">
{filesForUI.map((file, idx) => (<li key={`${file.name}-${file.size}-${idx}`} className="flex items-center justify-between border rounded-md p-2 bg-white">
<div className="flex items-center gap-3">
<ImageIcon className="h-6 w-6 text-green-500"/>
<div className="text-left">
<p className="font-medium text-green-700 truncate">
{file.name}
</p>
<p className="text-sm text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => removeUploadedFile(idx)}>
<X className="h-4 w-4"/>
</Button>
</li>))}
</ul>
</div>)}
</div>
{/* Extract */}
<div className="mt-4 flex justify-end gap-4">
<Button className="w-full h-12 gap-2" type="button" onClick={handleExtract} disabled={isExtracting || !filesForUI.length}>
{extractPaymentOCR.isPending
? "Extracting..."
: "Extract Payment Data"}
</Button>
</div>
{/* show extraction error if any */}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
</CardContent>
</Card>
<OCRDetailsModal open={showModal} onClose={() => setShowModal(false)} onSave={handleSave} rows={rows} setRows={setRows} columnKeys={modalColumns}/>
</div>);
}
// ---------------- Simple Modal (in-app popup) ----------------
export function OCRDetailsModal({ open, onClose, onSave, rows, setRows, columnKeys, }) {
if (!open)
return null;
//rows helper
const handleDeleteRow = (index) => {
setRows((prev) => prev.filter((_, i) => i !== index));
};
const handleAddRow = React.useCallback(() => {
setRows((prev) => {
const newRow = { __id: prev.length };
columnKeys.forEach((k) => {
newRow[k] = "";
});
return [...prev, newRow];
});
}, [setRows, columnKeys]);
const modalColumns = React.useMemo(() => {
// ensure ICN (if present) is moved to the end of the data columns
const reorderedKeys = [
...columnKeys.filter((k) => k !== "ICN"),
...(columnKeys.includes("ICN") ? ["ICN"] : []),
];
return reorderedKeys.map((key) => ({
id: key,
header: key,
cell: ({ row }) => {
const value = (row.original[key] ?? "");
return (<input className="w-full border rounded p-1" value={String(value)} onChange={(e) => {
const v = e.target.value;
setRows((prev) => {
const next = [...prev];
next[row.index] = {
...next[row.index],
__id: next[row.index].__id,
[key]: v,
};
return next;
});
}}/>);
},
}));
}, [columnKeys, setRows]);
const table = useReactTable({
data: rows,
columns: modalColumns,
getCoreRowModel: getCoreRowModel(),
});
return (<div className="fixed inset-0 z-50 flex items-start justify-center p-6">
<div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden/>
{/* larger modal, column layout so footer sticks to bottom */}
<div className="relative z-10 w-full max-w-[1600px] h-[92vh] bg-white rounded-lg shadow-2xl overflow-hidden flex flex-col">
{/* header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleAddRow}>
<Plus className="h-4 w-4 mr-2"/> Add Row
</Button>
<h3 className="text-lg font-medium ml-2">OCR Payment Details</h3>
</div>
<div>
<Button size="sm" variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
{/* body (scrollable) */}
<div className="p-4 overflow-auto flex-1">
<div className="min-w-max">
<table className="border-collapse border border-gray-300 w-full">
<thead>
{table.getHeaderGroups().map((hg) => (<tr key={hg.id} className="bg-gray-100">
{hg.headers.map((header) => (<th key={header.id} className="border p-2 text-left whitespace-nowrap">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>))}
<th className="border p-2">Actions</th>
</tr>))}
</thead>
<tbody>
{table.getRowModel().rows.map((r) => (<tr key={r.id}>
{r.getVisibleCells().map((cell) => (<td key={cell.id} className="border p-2 whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>))}
<td className="border p-2">
<Button size="sm" variant="destructive" onClick={() => handleDeleteRow(r.index)}>
Delete
</Button>
</td>
</tr>))}
</tbody>
</table>
</div>
</div>
{/* footer (always visible) */}
<div className="p-4 border-t flex justify-end">
<Button type="button" className="h-12" onClick={onSave}>
Save Edited Data
</Button>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,216 @@
import * as React from "react";
import * as XLSX from "xlsx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { FileText, X, Download, DatabaseIcon } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { QK_PAYMENTS_RECENT_BASE } from "@/components/payments/payments-recent-table";
import { MultipleFileUploadZone, } from "../file-upload/multiple-file-upload-zone";
const COLUMNS = [
{ key: "Patient Name", label: "Patient Name" },
{ key: "Member #", label: "Member #" },
{ key: "ICN", label: "ICN" },
{ key: "Submitted Code", label: "Submitted Code" },
{ key: "Paid Code", label: "Paid Code" },
{ key: "Tooth", label: "Tooth" },
{ key: "Date of Service", label: "Date of Service" },
{ key: "Submitted Amount", label: "Submitted ($)" },
{ key: "Allowed Amount", label: "Allowed ($)" },
{ key: "Paid Amount", label: "Paid ($)" },
];
const SUMMARY_FIELDS = [
"Source File",
"Payee ID",
"Business NPI",
"Run #",
"RA #",
"RA Date",
"Claim Detail Amount",
"Claim Adjustment Amount",
"Misc. Adjustment Amount",
"Payment Amount",
];
// Convert a string value to a number if it looks numeric, otherwise keep as string.
// Handles: "36.00" → 36, "$14,369.00" → 14369, "($3,107.39)" → -3107.39
function toExcelValue(val) {
if (!val)
return "";
const stripped = val
.replace(/\$/g, "")
.replace(/,/g, "")
.replace(/^\((.+)\)$/, "-$1") // (3,107.39) → -3107.39
.trim();
const num = Number(stripped);
return stripped !== "" && !isNaN(num) ? num : val;
}
function downloadExcel(rows, headers, sourceFileName) {
const wb = XLSX.utils.book_new();
// Sheet 1 — RA Summary (one row per uploaded PDF)
if (headers.length > 0) {
const summaryData = headers.map((h) => {
const out = {};
SUMMARY_FIELDS.forEach((f) => { out[f] = toExcelValue(h[f] ?? ""); });
return out;
});
const wsSummary = XLSX.utils.json_to_sheet(summaryData, {
header: SUMMARY_FIELDS,
});
XLSX.utils.book_append_sheet(wb, wsSummary, "RA Summary");
}
// Sheet 2 — Payment Data (one row per ICN)
const data = rows.map((row) => {
const out = {};
COLUMNS.forEach(({ key, label }) => { out[label] = toExcelValue(row[key] ?? ""); });
return out;
});
const wsData = XLSX.utils.json_to_sheet(data, {
header: COLUMNS.map((c) => c.label),
});
XLSX.utils.book_append_sheet(wb, wsData, "Payment Data");
const name = sourceFileName.replace(/\.pdf$/i, "") || "payment_extract";
XLSX.writeFile(wb, `${name}.xlsx`);
}
export default function PaymentUploadDocumentsBlock() {
const MAX_FILES = 10;
const ACCEPTED_FILE_TYPES = "application/pdf";
const uploadZoneRef = React.useRef(null);
const [filesForUI, setFilesForUI] = React.useState([]);
const [isUploading] = React.useState(false);
const [error, setError] = React.useState(null);
// ── shared extract helper ────────────────────────────────────────────────
const extractData = async (files) => {
const formData = new FormData();
files.forEach((file) => formData.append("files", file, file.name));
const res = await apiRequest("POST", "/api/payment-pdf/extract", formData);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || "Failed to extract PDF data");
}
const data = (await res.json());
return { rows: data.rows ?? [], headers: data.headers ?? [] };
};
const getFiles = () => {
const files = uploadZoneRef.current?.getFiles() ?? [];
if (!files.length) {
setError("Please upload at least one PDF file.");
return null;
}
setError(null);
return files;
};
// ── Extract & Download ───────────────────────────────────────────────────
const downloadMutation = useMutation({
mutationFn: async (files) => {
const { rows, headers } = await extractData(files);
return { rows, headers, sourceName: files[0]?.name ?? "payment_extract" };
},
onSuccess: ({ rows, headers, sourceName }) => {
if (rows.length === 0) {
toast({ title: "No data", description: "No rows were extracted from the PDF.", variant: "destructive" });
return;
}
downloadExcel(rows, headers, sourceName);
toast({ title: "Downloaded", description: `${rows.length} rows exported to Excel.` });
},
onError: (err) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
// ── Extract & Import ─────────────────────────────────────────────────────
const importMutation = useMutation({
mutationFn: async (files) => {
const { rows } = await extractData(files);
if (rows.length === 0)
throw new Error("No rows extracted from the PDF.");
const res = await apiRequest("POST", "/api/payment-pdf/import", { rows });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || "Import failed");
}
return res.json();
},
onSuccess: async (data) => {
toast({
title: "Imported",
description: `${data.paymentIds?.length ?? 0} payment(s) created successfully.`,
});
await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE });
},
onError: (err) => {
toast({ title: "Error", description: err.message, variant: "destructive" });
},
});
const handleZoneFilesChange = React.useCallback((files) => {
setFilesForUI(files);
setError(null);
}, []);
const removeUploadedFile = React.useCallback((index) => {
uploadZoneRef.current?.removeFile(index);
}, []);
const busy = downloadMutation.isPending || importMutation.isPending;
return (<div className="mb-8">
<Card>
<CardHeader>
<CardTitle>Upload Payment Documents</CardTitle>
<CardDescription>
Upload up to 10 MassHealth remittance PDFs. Extract and download as
Excel, or import directly into the database.
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-gray-100 p-4 rounded-md space-y-4">
<MultipleFileUploadZone ref={uploadZoneRef} isUploading={isUploading} acceptedFileTypes={ACCEPTED_FILE_TYPES} maxFiles={MAX_FILES} onFilesChange={handleZoneFilesChange}/>
{filesForUI.length > 0 && (<div>
<p className="text-sm text-gray-600 mb-2">
Uploaded ({filesForUI.length}/{MAX_FILES})
</p>
<ul className="space-y-2 max-h-48 overflow-auto">
{filesForUI.map((file, idx) => (<li key={`${file.name}-${file.size}-${idx}`} className="flex items-center justify-between border rounded-md p-2 bg-white">
<div className="flex items-center gap-3">
<FileText className="h-6 w-6 text-blue-500"/>
<div className="text-left">
<p className="font-medium text-blue-700 truncate">{file.name}</p>
<p className="text-sm text-gray-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => removeUploadedFile(idx)}>
<X className="h-4 w-4"/>
</Button>
</li>))}
</ul>
</div>)}
</div>
<div className="mt-4 flex gap-3">
{/* Extract & Download */}
<Button className="flex-1 h-12 gap-2 bg-blue-600 hover:bg-blue-700 text-white" type="button" disabled={busy || !filesForUI.length} onClick={() => {
const files = getFiles();
if (files)
downloadMutation.mutate(files);
}}>
<Download className="h-4 w-4"/>
{downloadMutation.isPending ? "Extracting…" : "Extract & Download"}
</Button>
{/* Extract & Import */}
<Button className="flex-1 h-12 gap-2 bg-teal-600 hover:bg-teal-700 text-white" type="button" disabled={busy || !filesForUI.length} onClick={() => {
const files = getFiles();
if (files)
importMutation.mutate(files);
}}>
<DatabaseIcon className="h-4 w-4"/>
{importMutation.isPending ? "Importing…" : "Extract & Import"}
</Button>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</CardContent>
</Card>
</div>);
}

View File

@@ -0,0 +1,90 @@
import { forwardRef, useEffect, useState } from "react";
import { PatientTable } from "../patients/patient-table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
import PaymentsRecentTable from "./payments-recent-table";
const PaymentsOfPatientModal = forwardRef(({ initialPatient = null, openInitially = false, onClose }, ref) => {
const [selectedPatient, setSelectedPatient] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [paymentsPage, setPaymentsPage] = useState(1);
// minimal, local scroll + cleanup — put inside PaymentsOfPatientModal
useEffect(() => {
if (!selectedPatient)
return;
const raf = requestAnimationFrame(() => {
const card = document.getElementById("payments-for-patient-card");
const main = document.querySelector("main"); // your app's scroll container
if (card && main instanceof HTMLElement) {
const parentRect = main.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const relativeTop = cardRect.top - parentRect.top + main.scrollTop;
const offset = 8;
main.scrollTo({
top: Math.max(0, relativeTop - offset),
behavior: "smooth",
});
}
});
// cleanup: when selectedPatient changes (ddmodal closes) or component unmounts,
// reset the main scroll to top so other pages are not left scrolled.
return () => {
cancelAnimationFrame(raf);
const main = document.querySelector("main");
if (main instanceof HTMLElement) {
// immediate reset (no animation) so navigation to other pages starts at top
main.scrollTo({ top: 0, behavior: "auto" });
}
};
}, [selectedPatient]);
// when parent provides an initialPatient and openInitially flag, apply it
useEffect(() => {
if (initialPatient) {
setSelectedPatient(initialPatient);
setPaymentsPage(1);
}
if (openInitially) {
setIsModalOpen(true);
}
}, [initialPatient, openInitially]);
const handleSelectPatient = (patient) => {
if (patient) {
setSelectedPatient(patient);
setPaymentsPage(1);
setIsModalOpen(true);
}
else {
setSelectedPatient(null);
setIsModalOpen(false);
}
};
return (<div className="space-y-8 py-8">
{/* Payments Section */}
{selectedPatient && (<Card id="payments-for-patient-card">
<CardHeader>
<CardTitle>
Payments for {selectedPatient.firstName}{" "}
{selectedPatient.lastName}
</CardTitle>
<CardDescription>
Displaying recent payments for the selected patient.
</CardDescription>
</CardHeader>
<CardContent>
<PaymentsRecentTable patientId={selectedPatient.id} allowEdit allowDelete onPageChange={setPaymentsPage}/>
</CardContent>
</Card>)}
{/* Patients Section */}
<Card>
<CardHeader>
<CardTitle>Patient Records</CardTitle>
<CardDescription>
Select any patient and View all their recent payments.
</CardDescription>
</CardHeader>
<CardContent>
<PatientTable allowView allowCheckbox onSelectPatient={handleSelectPatient}/>
</CardContent>
</Card>
</div>);
});
export default PaymentsOfPatientModal;

View File

@@ -0,0 +1,776 @@
import { useState, useEffect, useMemo } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Edit, Delete, Clock, CheckCircle, AlertCircle, TrendingUp, ThumbsDown, DollarSign, Ban, Paperclip, } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { formatDateToHumanReadable } from "@/utils/dateUtils";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import { Checkbox } from "@/components/ui/checkbox";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import LoadingScreen from "../ui/LoadingScreen";
import EditPaymentModal from "./payment-edit-modal";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ConfirmationDialog } from "../ui/confirmationDialog";
import { getPageNumbers } from "@/utils/pageNumberGenerator";
// 🔑 exported base key (so others can invalidate all pages/filters)
export const QK_PAYMENTS_RECENT_BASE = ["payments-recent"];
// 🔑 exported helper for specific pages/scopes
export const qkPaymentsRecent = (opts) => opts.patientId
? [
...QK_PAYMENTS_RECENT_BASE,
"patient",
opts.patientId,
opts.page,
]
: [...QK_PAYMENTS_RECENT_BASE, "global", opts.page];
export default function PaymentsRecentTable({ allowEdit, allowDelete, allowCheckbox, onSelectPayment, onPageChange, patientId, }) {
const { toast } = useToast();
const [isEditPaymentOpen, setIsEditPaymentOpen] = useState(false);
const [isDeletePaymentOpen, setIsDeletePaymentOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const paymentsPerPage = 5;
const offset = (currentPage - 1) * paymentsPerPage;
const [currentPayment, setCurrentPayment] = useState(undefined);
const [selectedPaymentId, setSelectedPaymentId] = useState(null);
const [checkedPaymentIds, setCheckedPaymentIds] = useState(new Set());
const [isMhChecking, setIsMhChecking] = useState(false);
const [editingMhPaidId, setEditingMhPaidId] = useState(null);
const [editingMhPaidValue, setEditingMhPaidValue] = useState("");
const [editingCopaymentId, setEditingCopaymentId] = useState(null);
const [editingCopaymentValue, setEditingCopaymentValue] = useState("");
const [isRevertOpen, setIsRevertOpen] = useState(false);
const [revertPaymentId, setRevertPaymentId] = useState(null);
const handleSelectPayment = (payment) => {
const isSelected = selectedPaymentId === payment.id;
const newSelectedId = isSelected ? null : payment.id;
setSelectedPaymentId(Number(newSelectedId));
if (onSelectPayment) {
onSelectPayment(isSelected ? null : payment);
}
};
const handleToggleCheck = (paymentId) => {
setCheckedPaymentIds((prev) => {
const next = new Set(prev);
if (next.has(paymentId)) {
next.delete(paymentId);
}
else {
next.add(paymentId);
}
return next;
});
};
const queryKey = qkPaymentsRecent({
patientId: patientId ?? undefined,
page: currentPage,
});
const { data: paymentsData, isLoading, isError, } = useQuery({
queryKey,
queryFn: async () => {
const endpoint = patientId
? `/api/payments/patient/${patientId}?limit=${paymentsPerPage}&offset=${offset}`
: `/api/payments/recent?limit=${paymentsPerPage}&offset=${offset}`;
const res = await apiRequest("GET", endpoint);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.message || "Failed to fetch payments");
}
return res.json();
},
placeholderData: { payments: [], totalCount: 0 },
});
const currentPageIds = (paymentsData?.payments ?? []).map((p) => p.id);
const allOnPageChecked = currentPageIds.length > 0 &&
currentPageIds.every((id) => checkedPaymentIds.has(id));
const someOnPageChecked = !allOnPageChecked && currentPageIds.some((id) => checkedPaymentIds.has(id));
const handleToggleAll = () => {
setCheckedPaymentIds((prev) => {
const next = new Set(prev);
if (allOnPageChecked) {
currentPageIds.forEach((id) => next.delete(id));
}
else {
currentPageIds.forEach((id) => next.add(id));
}
return next;
});
};
const updatePaymentMutation = useMutation({
mutationFn: async (data) => {
const response = await apiRequest("PUT", `/api/payments/${data.paymentId}`, {
data: data,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update Payment");
}
return response.json();
},
onSuccess: async (updated, { paymentId }) => {
toast({
title: "Success",
description: "Payment updated successfully!",
});
// 🔄 refresh this table page
await queryClient.invalidateQueries({
queryKey: QK_PAYMENTS_RECENT_BASE,
});
// Fetch updated payment and set into local state
const refreshedPayment = await apiRequest("GET", `/api/payments/${paymentId}`).then((res) => res.json());
setCurrentPayment(refreshedPayment); // <-- keep modal in sync
},
onError: (error) => {
toast({
title: "Error",
description: `Update failed: ${error.message}`,
variant: "destructive",
});
},
});
const updatePaymentStatusMutation = useMutation({
mutationFn: async ({ paymentId, status, }) => {
const response = await apiRequest("PATCH", `/api/payments/${paymentId}/status`, {
data: { status },
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update payment status");
}
return response.json();
},
onSuccess: async (updated, { paymentId }) => {
toast({
title: "Success",
description: "Payment Status updated successfully!",
});
await queryClient.invalidateQueries({
queryKey: QK_PAYMENTS_RECENT_BASE,
});
// Fetch updated payment and set into local state
const refreshedPayment = await apiRequest("GET", `/api/payments/${paymentId}`).then((res) => res.json());
setCurrentPayment(refreshedPayment); // <-- keep modal in sync
},
onError: (error) => {
toast({
title: "Error",
description: `Status update failed: ${error.message}`,
variant: "destructive",
});
},
});
const fullPaymentMutation = useMutation({
mutationFn: async ({ paymentId, type, }) => {
const endpoint = type === "pay"
? `/api/payments/${paymentId}/pay-absolute-full-claim`
: `/api/payments/${paymentId}/revert-full-claim`;
const response = await apiRequest("PUT", endpoint);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update Payment");
}
return response.json();
},
onSuccess: async () => {
toast({
title: "Success",
description: "Payment updated successfully!",
});
await queryClient.invalidateQueries({
queryKey: QK_PAYMENTS_RECENT_BASE,
});
},
onError: (error) => {
toast({
title: "Error",
description: `Operation failed: ${error.message}`,
variant: "destructive",
});
},
});
const handlePayAbsoluteFullDue = (paymentId) => {
fullPaymentMutation.mutate({ paymentId, type: "pay" });
};
const handleRevert = () => {
if (!revertPaymentId)
return;
fullPaymentMutation.mutate({
paymentId: revertPaymentId,
type: "revert",
});
setRevertPaymentId(null);
setIsRevertOpen(false);
};
const deletePaymentMutation = useMutation({
mutationFn: async (id) => {
const res = await apiRequest("DELETE", `/api/payments/${id}`);
return;
},
onSuccess: async () => {
setIsDeletePaymentOpen(false);
await queryClient.invalidateQueries({
queryKey: QK_PAYMENTS_RECENT_BASE,
});
toast({
title: "Deleted",
description: "Payment deleted successfully",
variant: "default",
});
},
onError: (error) => {
toast({
title: "Error",
description: `Failed to delete payment: ${error.message}`,
variant: "destructive",
});
},
});
const handleEditPayment = (payment) => {
setCurrentPayment(payment);
setIsEditPaymentOpen(true);
};
const handleDeletePayment = (payment) => {
setCurrentPayment(payment);
setIsDeletePaymentOpen(true);
};
const handleConfirmDeletePayment = async () => {
if (currentPayment) {
if (typeof currentPayment.id === "number") {
deletePaymentMutation.mutate(currentPayment.id);
}
else {
toast({
title: "Error",
description: "Selected Payment is missing an ID for deletion.",
variant: "destructive",
});
}
}
else {
toast({
title: "Error",
description: "No Payment selected for deletion.",
variant: "destructive",
});
}
};
//VOID and UNVOID Feature
const handleVoid = (paymentId) => {
updatePaymentStatusMutation.mutate({ paymentId, status: "VOID" });
};
const handleUnvoid = (paymentId) => {
updatePaymentStatusMutation.mutate({ paymentId, status: "PENDING" });
};
const [isVoidOpen, setIsVoidOpen] = useState(false);
const [voidPaymentId, setVoidPaymentId] = useState(null);
const [isUnvoidOpen, setIsUnvoidOpen] = useState(false);
const [unvoidPaymentId, setUnvoidPaymentId] = useState(null);
const [isPaidInFullOpen, setIsPaidInFullOpen] = useState(false);
const [paidInFullPaymentId, setPaidInFullPaymentId] = useState(null);
const [isRevertPaidOpen, setIsRevertPaidOpen] = useState(false);
const [revertPaidPaymentId, setRevertPaidPaymentId] = useState(null);
const handleConfirmVoid = () => {
if (!voidPaymentId)
return;
handleVoid(voidPaymentId);
setVoidPaymentId(null);
setIsVoidOpen(false);
};
const handleConfirmUnvoid = () => {
if (!unvoidPaymentId)
return;
handleUnvoid(unvoidPaymentId);
setUnvoidPaymentId(null);
setIsUnvoidOpen(false);
};
const handleConfirmPaidInFull = () => {
if (!paidInFullPaymentId)
return;
updatePaymentStatusMutation.mutate({ paymentId: paidInFullPaymentId, status: "PAID" });
setPaidInFullPaymentId(null);
setIsPaidInFullOpen(false);
};
const handleConfirmRevertPaid = () => {
if (!revertPaidPaymentId)
return;
updatePaymentStatusMutation.mutate({ paymentId: revertPaidPaymentId, status: "PENDING" });
setRevertPaidPaymentId(null);
setIsRevertPaidOpen(false);
};
// Pagination
useEffect(() => {
if (onPageChange)
onPageChange(currentPage);
}, [currentPage, onPageChange]);
useEffect(() => {
setCurrentPage(1);
}, [patientId]);
const totalPages = useMemo(() => Math.ceil((paymentsData?.totalCount || 0) / paymentsPerPage), [paymentsData?.totalCount, paymentsPerPage]);
const startItem = offset + 1;
const endItem = Math.min(offset + paymentsPerPage, paymentsData?.totalCount || 0);
const getName = (p) => p.patient
? `${p.patient.firstName} ${p.patient.lastName}`.trim()
: (p.patientName ?? "Unknown");
const getInitials = (fullName) => {
const parts = fullName.trim().split(/\s+/);
const filteredParts = parts.filter((part) => part.length > 0);
if (filteredParts.length === 0) {
return "";
}
const firstInitial = filteredParts[0].charAt(0).toUpperCase();
if (filteredParts.length === 1) {
return firstInitial;
}
else {
const lastInitial = filteredParts[filteredParts.length - 1].charAt(0).toUpperCase();
return firstInitial + lastInitial;
}
};
const getAvatarColor = (id) => {
const colorClasses = [
"bg-blue-500",
"bg-teal-500",
"bg-amber-500",
"bg-rose-500",
"bg-indigo-500",
"bg-green-500",
"bg-purple-500",
];
return colorClasses[id % colorClasses.length];
};
const getStatusInfo = (status) => {
switch (status) {
case "PENDING":
return {
label: "Pending",
color: "bg-red-100 text-red-800",
icon: <Clock className="h-3 w-3 mr-1"/>,
};
case "PARTIALLY_PAID":
return {
label: "Partially Paid",
color: "bg-blue-100 text-blue-800",
icon: <DollarSign className="h-3 w-3 mr-1"/>,
};
case "PAID":
return {
label: "Paid in Full",
color: "bg-teal-100 text-teal-800",
icon: <CheckCircle className="h-3 w-3 mr-1"/>,
};
case "OVERPAID":
return {
label: "Overpaid",
color: "bg-purple-100 text-purple-800",
icon: <TrendingUp className="h-3 w-3 mr-1"/>,
};
case "DENIED":
return {
label: "Denied",
color: "bg-red-100 text-red-800",
icon: <ThumbsDown className="h-3 w-3 mr-1"/>,
};
case "VOID":
return {
label: "Void",
color: "bg-gray-100 text-gray-800",
icon: <Ban className="h-3 w-3 mr-1"/>,
};
default:
return {
label: status
? status.charAt(0).toUpperCase() +
status.slice(1).toLowerCase()
: "Unknown",
color: "bg-gray-100 text-gray-800",
icon: <AlertCircle className="h-3 w-3 mr-1"/>,
};
}
};
return (<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Check MH Payment action bar */}
{allowCheckbox && checkedPaymentIds.size > 0 && (<div className="flex items-center gap-3 px-4 py-2 bg-blue-50 border-b border-blue-200">
<span className="text-sm text-blue-700 font-medium">
{checkedPaymentIds.size} record{checkedPaymentIds.size > 1 ? "s" : ""} selected
</span>
<Button size="sm" variant="default" disabled={isMhChecking} onClick={async () => {
setIsMhChecking(true);
let successCount = 0;
let failCount = 0;
for (const paymentId of checkedPaymentIds) {
try {
const res = await apiRequest("PATCH", `/api/payments/${paymentId}/mh-payment-check`);
if (res.ok) {
successCount++;
}
else {
const err = await res.json();
console.error(`MH check failed for payment ${paymentId}:`, err.message);
failCount++;
}
}
catch (e) {
console.error(`MH check error for payment ${paymentId}:`, e);
failCount++;
}
}
setIsMhChecking(false);
setCheckedPaymentIds(new Set());
await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE });
if (failCount === 0) {
toast({ title: "MH Payment Check Complete", description: `${successCount} record(s) updated.` });
}
else {
toast({
title: "MH Payment Check Done",
description: `${successCount} succeeded, ${failCount} failed. Check credentials or claim numbers.`,
variant: "destructive",
});
}
}}>
{isMhChecking ? "Checking..." : "Check Single MH Payment"}
</Button>
<Button size="sm" variant="ghost" className="text-blue-600" onClick={() => setCheckedPaymentIds(new Set())}>
Clear
</Button>
</div>)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{allowCheckbox && (<TableHead className="w-10">
<Checkbox checked={allOnPageChecked} data-state={someOnPageChecked ? "indeterminate" : undefined} onCheckedChange={handleToggleAll} aria-label="Select all on page"/>
</TableHead>)}
<TableHead>Claim No.</TableHead>
<TableHead>Patient Name</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Service Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Attachments</TableHead>
<TableHead>Provider</TableHead>
<TableHead>MH Paid</TableHead>
<TableHead>Copayment</TableHead>
<TableHead>Adjustment</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>Payment ID</TableHead>
<TableHead>Claim ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<LoadingScreen />
</TableCell>
</TableRow>) : isError ? (<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-red-500">
Error loading payments.
</TableCell>
</TableRow>) : (paymentsData?.payments?.length ?? 0) === 0 ? (<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
No payments found
</TableCell>
</TableRow>) : (paymentsData?.payments.map((payment) => {
const totalBilled = Number(payment.totalBilled || 0);
const totalPaid = Number(payment.totalPaid || 0);
const mhPaid = Number(payment.mhPaidAmount || 0);
const copayment = Number(payment.copayment || 0);
const adjustment = Number(payment.adjustment || 0);
const totalDue = Math.max(0, totalBilled - mhPaid - copayment - adjustment);
const totalCollected = mhPaid + copayment;
const displayName = getName(payment);
const submittedOn = payment.serviceLines?.[0]?.procedureDate ??
payment.claim?.createdAt ??
payment.createdAt ??
payment.serviceLineTransactions?.[0]?.receivedDate ??
null;
return (<TableRow key={payment.id}>
{allowCheckbox && (<TableCell className="w-10">
<Checkbox checked={checkedPaymentIds.has(payment.id)} onCheckedChange={() => handleToggleCheck(payment.id)} aria-label={`Select payment ${payment.id}`}/>
</TableCell>)}
<TableCell>
{payment.claim?.claimNumber ? (<span className="text-sm font-mono">{payment.claim.claimNumber}</span>) : payment.notes?.startsWith("PDF import") ? (<span className="text-xs font-medium bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">PDF Import</span>) : (<span className="text-gray-400"></span>)}
</TableCell>
<TableCell>
<div className="flex items-center">
<Avatar className={`h-10 w-10 ${getAvatarColor(Number(payment.id))}`}>
<AvatarFallback className="text-white">
{getInitials(displayName)}
</AvatarFallback>
</Avatar>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{displayName}
</div>
<div className="text-sm text-gray-500">
PID-{payment.patientId?.toString().padStart(4, "0")}
</div>
</div>
</div>
</TableCell>
{/* 💰 Billed / Collected / Due breakdown */}
<TableCell>
<div className="flex flex-col gap-1">
<span>
<strong>Total Billed:</strong> ${totalBilled.toFixed(2)}
</span>
<span>
<strong>Collected:</strong> ${totalCollected.toFixed(2)}
</span>
{adjustment > 0 && (<span>
<strong>Adjustment:</strong>{" "}
<span className="text-orange-600">-${adjustment.toFixed(2)}</span>
</span>)}
<span>
<strong>Balance:</strong>{" "}
{totalDue > 0 ? (<span className="text-yellow-600">${totalDue.toFixed(2)}</span>) : (<span className="text-green-600">Settled</span>)}
</span>
</div>
</TableCell>
<TableCell>
{formatDateToHumanReadable(submittedOn)}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{payment.status === "VOID" ? (<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 flex items-center w-fit">
<Ban className="h-3 w-3 mr-1"/>Void
</span>) : payment.status === "PAID" ? (<span className="px-2 py-1 text-xs font-medium rounded-full bg-teal-100 text-teal-800 flex items-center w-fit">
<CheckCircle className="h-3 w-3 mr-1"/>Paid in Full
</span>) : (<span className="px-2 py-1 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 flex items-center w-fit">
<Clock className="h-3 w-3 mr-1"/>Balance
</span>)}
{payment.commissionBatchItems?.length > 0 && (<span className="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 text-purple-800 w-fit" title={`Commissioned on ${new Date(payment.commissionBatchItems[0].commissionBatch.createdAt).toLocaleDateString()}`}>
Commissioned
</span>)}
</div>
</TableCell>
<TableCell>
{payment.claim?.claimFiles && payment.claim.claimFiles.length > 0 ? (<ul className="space-y-1">
{payment.claim.claimFiles.map((f) => (<li key={f.id ?? f.filename} className="flex items-center gap-1 text-xs text-gray-700">
<Paperclip className="h-3 w-3 text-gray-400 shrink-0"/>
<span className="truncate max-w-[140px]" title={f.filename}>
{f.filename}
</span>
</li>))}
</ul>) : (<span className="text-xs text-gray-400"></span>)}
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{payment.npiProvider?.providerName ?? "—"}
</div>
</TableCell>
<TableCell>
{editingMhPaidId === payment.id ? (<input type="number" min="0" step="0.01" autoFocus className="w-24 border border-blue-400 rounded px-1 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" value={editingMhPaidValue} onChange={(e) => setEditingMhPaidValue(e.target.value)} onKeyDown={async (e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
else if (e.key === "Escape") {
setEditingMhPaidId(null);
}
}} onBlur={async () => {
const val = parseFloat(editingMhPaidValue);
if (!isNaN(val) && val >= 0) {
try {
const res = await apiRequest("PATCH", `/api/payments/${payment.id}/mh-paid-amount`, { mhPaidAmount: val });
if (res.ok) {
await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE });
}
else {
toast({ title: "Error", description: "Failed to save MH paid amount.", variant: "destructive" });
}
}
catch {
toast({ title: "Error", description: "Failed to save MH paid amount.", variant: "destructive" });
}
}
setEditingMhPaidId(null);
}}/>) : (<span className="text-sm font-medium text-green-700 cursor-pointer hover:underline hover:text-green-900" title="Click to edit" onClick={() => {
setEditingMhPaidId(payment.id);
setEditingMhPaidValue(payment.mhPaidAmount != null
? Number(payment.mhPaidAmount).toFixed(2)
: "0.00");
}}>
{payment.mhPaidAmount != null
? `$${Number(payment.mhPaidAmount).toFixed(2)}`
: <span className="text-gray-400 font-normal"></span>}
</span>)}
</TableCell>
{/* Copayment */}
<TableCell>
{editingCopaymentId === payment.id ? (<input type="number" min="0" step="0.01" autoFocus className="w-24 border border-blue-400 rounded px-1 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300" value={editingCopaymentValue} onChange={(e) => setEditingCopaymentValue(e.target.value)} onKeyDown={(e) => {
if (e.key === "Enter")
e.currentTarget.blur();
else if (e.key === "Escape")
setEditingCopaymentId(null);
}} onBlur={async () => {
const val = parseFloat(editingCopaymentValue);
if (!isNaN(val) && val >= 0) {
try {
const res = await apiRequest("PATCH", `/api/payments/${payment.id}/copayment`, { copayment: val });
if (res.ok) {
await queryClient.invalidateQueries({ queryKey: QK_PAYMENTS_RECENT_BASE });
}
else {
toast({ title: "Error", description: "Failed to save copayment.", variant: "destructive" });
}
}
catch {
toast({ title: "Error", description: "Failed to save copayment.", variant: "destructive" });
}
}
setEditingCopaymentId(null);
}}/>) : (<span className="text-sm font-medium text-blue-700 cursor-pointer hover:underline hover:text-blue-900" title="Click to edit" onClick={() => {
setEditingCopaymentId(payment.id);
setEditingCopaymentValue(Number(payment.copayment ?? 0).toFixed(2));
}}>
${Number(payment.copayment ?? 0).toFixed(2)}
</span>)}
</TableCell>
{/* Adjustment — auto-computed: totalBilled - mhPaid - copayment */}
<TableCell>
<span className="text-sm font-medium text-orange-700">
${adjustment.toFixed(2)}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{allowDelete && (<Button onClick={() => {
handleDeletePayment(payment);
}} className="text-red-600 hover:text-red-900" aria-label="Delete Payment" variant="ghost" size="icon">
<Delete />
</Button>)}
{allowEdit && (<Button variant="ghost" size="icon" onClick={() => {
handleEditPayment(payment);
}} className="text-blue-600 hover:text-blue-800 hover:bg-blue-50">
<Edit className="h-4 w-4"/>
</Button>)}
{/* Paid in Full — only when not already paid or voided */}
{payment.status !== "PAID" &&
payment.status !== "VOID" &&
payment.status !== "DENIED" && (<Button variant="default" size="sm" className="bg-teal-600 hover:bg-teal-700 text-white" onClick={() => {
setPaidInFullPaymentId(payment.id);
setIsPaidInFullOpen(true);
}}>
Paid in Full
</Button>)}
{/* Revert — only when already Paid in Full */}
{payment.status === "PAID" && (<Button variant="outline" size="sm" className="border-teal-400 text-teal-700 hover:bg-teal-50" onClick={() => {
setRevertPaidPaymentId(payment.id);
setIsRevertPaidOpen(true);
}}>
Revert
</Button>)}
{/* Show Void unless already voided or denied */}
{payment.status !== "VOID" &&
payment.status !== "DENIED" && (<Button variant="outline" size="sm" onClick={() => {
setVoidPaymentId(payment.id);
setIsVoidOpen(true);
}}>
Void
</Button>)}
{/* When VOID → Unvoid */}
{payment.status === "VOID" && (<Button variant="outline" size="sm" onClick={() => {
setUnvoidPaymentId(payment.id);
setIsUnvoidOpen(true);
}}>
Unvoid
</Button>)}
</div>
</TableCell>
<TableCell className="text-xs text-gray-500">
{typeof payment.id === "number"
? `PAY-${payment.id.toString().padStart(4, "0")}`
: "N/A"}
</TableCell>
<TableCell className="text-xs text-gray-500">
{typeof payment.claimId === "number"
? `CLM-${payment.claimId.toString().padStart(4, "0")}`
: "N/A"}
</TableCell>
</TableRow>);
}))}
</TableBody>
</Table>
</div>
{/* Revert Confirmation Dialog */}
<ConfirmationDialog isOpen={isRevertOpen} title="Confirm Revert" message={`Do you want to revert all Service Line payments for Payment ID: ${revertPaymentId}?`} confirmLabel="Revert" confirmColor="bg-yellow-600 hover:bg-yellow-700" onConfirm={handleRevert} onCancel={() => setIsRevertOpen(false)}/>
{/* Revert Paid in Full Confirmation Dialog */}
<ConfirmationDialog isOpen={isRevertPaidOpen} title="Revert Paid in Full?" message="This will revert the status back to Balance. The amounts stay unchanged. Continue?" confirmLabel="Revert" confirmColor="bg-yellow-600 hover:bg-yellow-700" onConfirm={handleConfirmRevertPaid} onCancel={() => setIsRevertPaidOpen(false)}/>
{/* Paid in Full Confirmation Dialog */}
<ConfirmationDialog isOpen={isPaidInFullOpen} title="Mark as Paid in Full?" message="This will set the status to Paid in Full and close the balance for this payment. Continue?" confirmLabel="Paid in Full" confirmColor="bg-teal-600 hover:bg-teal-700" onConfirm={handleConfirmPaidInFull} onCancel={() => setIsPaidInFullOpen(false)}/>
{/* NEW: Void Confirmation Dialog */}
<ConfirmationDialog isOpen={isVoidOpen} title="Confirm Void" message={`Mark this payment as VOID? It will be excluded from balances and Calculations.`} confirmLabel="Void" confirmColor="bg-gray-700 hover:bg-gray-800" onConfirm={handleConfirmVoid} onCancel={() => setIsVoidOpen(false)}/>
{/* NEW: Unvoid Confirmation Dialog */}
<ConfirmationDialog isOpen={isUnvoidOpen} title="Confirm Unvoid" message={`Restore this payment to a normal state (PENDING)?`} confirmLabel="Unvoid" confirmColor="bg-blue-600 hover:bg-blue-700" onConfirm={handleConfirmUnvoid} onCancel={() => setIsUnvoidOpen(false)}/>
<DeleteConfirmationDialog isOpen={isDeletePaymentOpen} onConfirm={handleConfirmDeletePayment} onCancel={() => setIsDeletePaymentOpen(false)} entityName={`PaymentID : ${currentPayment?.id}`}/>
{isEditPaymentOpen && currentPayment && (<EditPaymentModal isOpen={isEditPaymentOpen} onOpenChange={(open) => setIsEditPaymentOpen(open)} onClose={() => setIsEditPaymentOpen(false)} payment={currentPayment} onEditServiceLine={(updatedPayment) => {
updatePaymentMutation.mutate(updatedPayment);
}} isUpdatingServiceLine={updatePaymentMutation.isPending} onUpdateStatus={(paymentId, status) => {
updatePaymentStatusMutation.mutate({ paymentId, status });
}} isUpdatingStatus={updatePaymentStatusMutation.isPending}/>)}
{/* Pagination */}
{totalPages > 1 && (<div className="bg-white px-4 py-3 border-t border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground mb-2 sm:mb-0 whitespace-nowrap">
Showing {startItem}{endItem} of {paymentsData?.totalCount || 0}{" "}
results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map((page, idx) => (<PaginationItem key={idx}>
{page === "..." ? (<span className="px-2 text-gray-500">...</span>) : (<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(page);
}} isActive={currentPage === page}>
{page}
</PaginationLink>)}
</PaginationItem>))}
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}} className={currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>)}
</div>);
}

View File

@@ -0,0 +1,140 @@
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { PROCEDURE_COMBOS, COMBO_CATEGORIES, } from "@/utils/procedureCombos";
/* =========================================================
DIRECT COMBO BUTTONS (TOP SECTION)
========================================================= */
export function DirectComboButtons({ onDirectCombo, }) {
return (<div className="space-y-6">
{/* Section Title */}
<div className="text-sm font-semibold text-muted-foreground">
Direct Claim Submission Buttons
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* CHILD RECALL */}
<DirectGroup title="Child Recall" combos={[
"childRecallDirect",
"childRecallDirect2BW",
"childRecallDirect4BW",
"childRecallDirect2PA2BW",
"childRecallDirect2PA4BW",
"childRecallDirect3PA2BW",
"childRecallDirect3PA",
"childRecallDirect4PA",
"childRecallDirectPANO",
]} labelMap={{
childRecallDirect: "Direct",
childRecallDirect2BW: "Direct 2BW",
childRecallDirect4BW: "Direct 4BW",
childRecallDirect2PA2BW: "Direct 2PA 2BW",
childRecallDirect2PA4BW: "Direct 2PA 4BW",
childRecallDirect3PA2BW: "Direct 3PA 2BW",
childRecallDirect3PA: "Direct 3PA",
childRecallDirect4PA: "Direct 4PA",
childRecallDirectPANO: "Direct Pano",
}} onSelect={onDirectCombo}/>
{/* ADULT RECALL */}
<DirectGroup title="Adult Recall" combos={[
"adultRecallDirect",
"adultRecallDirect2BW",
"adultRecallDirect4BW",
"adultRecallDirect2PA2BW",
"adultRecallDirect2PA4BW",
"adultRecallDirect4PA",
"adultRecallDirectPano",
]} labelMap={{
adultRecallDirect: "Direct",
adultRecallDirect2BW: "Direct 2BW",
adultRecallDirect4BW: "Direct 4BW",
adultRecallDirect2PA2BW: "Direct 2PA 2BW",
adultRecallDirect2PA4BW: "Direct 2PA 4BW",
adultRecallDirect4PA: "Direct 4PA",
adultRecallDirectPano: "Direct Pano",
}} onSelect={onDirectCombo}/>
{/* ORTH */}
<DirectGroup title="Orth" combos={[
"orthPreExamDirect",
"orthRecordDirect",
"orthPerioVisitDirect",
"orthRetentionDirect",
]} onSelect={onDirectCombo}/>
</div>
</div>);
}
/* =========================================================
REGULAR COMBO BUTTONS (BOTTOM SECTION)
========================================================= */
export function RegularComboButtons({ onRegularCombo, excludeCategories, excludeIds, }) {
return (<div className="space-y-4 mt-8">
{Object.entries(COMBO_CATEGORIES).filter(([section]) => !excludeCategories?.includes(section)).map(([section, ids]) => (<div key={section}>
<div className="mb-3 text-sm font-semibold opacity-70">
{section}
</div>
<div className="flex flex-wrap gap-1">
{ids.filter((id) => !excludeIds?.includes(id)).map((id) => {
const b = PROCEDURE_COMBOS[id];
if (!b)
return null;
const tooltipText = b.codes
.map((code, idx) => {
const tooth = b.toothNumbers?.[idx];
return tooth ? `${code} (tooth ${tooth})` : code;
})
.join(", ");
return (<Tooltip key={id}>
<TooltipTrigger asChild>
<Button variant="secondary" onClick={() => onRegularCombo(id)} aria-label={`${b.label} — codes: ${tooltipText}`}>
{b.label}
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="center">
<div className="text-sm max-w-xs break-words">
{tooltipText}
</div>
</TooltipContent>
</Tooltip>);
})}
</div>
</div>))}
</div>);
}
/* =========================================================
INTERNAL HELPERS
========================================================= */
function DirectGroup({ title, combos, labelMap, onSelect, }) {
return (<div className="space-y-2">
<div className="text-sm font-medium opacity-80">{title}</div>
<div className="flex flex-wrap gap-2">
{combos.map((id) => {
const b = PROCEDURE_COMBOS[id];
if (!b)
return null;
const tooltipText = b.codes
.map((code, idx) => {
const tooth = b.toothNumbers?.[idx];
return tooth ? `${code} (tooth ${tooth})` : code;
})
.join(", ");
return (<Tooltip key={id}>
<TooltipTrigger asChild>
<Button variant="secondary" onClick={() => onSelect(id)} aria-label={`${b.label} — codes: ${tooltipText}`}>
{labelMap?.[id] ?? b.label}
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="center">
<div className="text-sm max-w-xs break-words">
{tooltipText}
</div>
</TooltipContent>
</Tooltip>);
})}
</div>
</div>);
}

View File

@@ -0,0 +1,232 @@
import React, { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import PatientsBalancesList from "./patients-balances-list";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import ExportReportButton from "./export-button";
function fmtCurrency(v) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(v);
}
export default function CollectionsByDoctorReport({ startDate, endDate, npiProviderId, }) {
const [staffId, setStaffId] = useState("");
const perPage = 10;
const [cursorStack, setCursorStack] = useState([null]);
const [cursorIndex, setCursorIndex] = useState(0);
const currentCursor = cursorStack[cursorIndex] ?? null;
const pageIndex = cursorIndex + 1;
// load staffs list for selector
const { data: staffs } = useQuery({
queryKey: ["staffs"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/staffs");
if (!res.ok) {
const b = await res
.json()
.catch(() => ({ message: "Failed to load staffs" }));
throw new Error(b.message || "Failed to load staffs");
}
return res.json();
},
staleTime: 60_000,
});
// --- balances query (paged rows) ---
const { data: balancesResult, isLoading: isLoadingBalances, isError: isErrorBalances, refetch: refetchBalances, isFetching: isFetchingBalances, } = useQuery({
queryKey: [
"collections-by-doctor-balances",
staffId,
currentCursor,
perPage,
startDate,
endDate,
npiProviderId ?? "all",
],
queryFn: async () => {
const params = new URLSearchParams();
params.set("limit", String(perPage));
if (currentCursor)
params.set("cursor", currentCursor);
if (staffId)
params.set("staffId", staffId);
if (startDate)
params.set("from", startDate);
if (endDate)
params.set("to", endDate);
if (npiProviderId)
params.set("npiProviderId", String(npiProviderId));
const res = await apiRequest("GET", `/api/payments-reports/by-doctor/balances?${params.toString()}`);
if (!res.ok) {
const b = await res
.json()
.catch(() => ({ message: "Failed to load collections balances" }));
throw new Error(b.message || "Failed to load collections balances");
}
return res.json();
},
enabled: Boolean(staffId),
});
// --- summary query (staff summary) ---
const { data: summaryData, isLoading: isLoadingSummary, isError: isErrorSummary, refetch: refetchSummary, isFetching: isFetchingSummary, } = useQuery({
queryKey: ["collections-by-doctor-summary", staffId, startDate, endDate, npiProviderId ?? "all"],
queryFn: async () => {
const params = new URLSearchParams();
if (staffId)
params.set("staffId", staffId);
if (startDate)
params.set("from", startDate);
if (endDate)
params.set("to", endDate);
if (npiProviderId)
params.set("npiProviderId", String(npiProviderId));
const res = await apiRequest("GET", `/api/payments-reports/by-doctor/summary?${params.toString()}`);
if (!res.ok) {
const b = await res
.json()
.catch(() => ({ message: "Failed to load collections summary" }));
throw new Error(b.message || "Failed to load collections summary");
}
return res.json();
},
enabled: Boolean(staffId),
});
const balances = balancesResult?.balances ?? [];
const totalCount = balancesResult?.totalCount ?? undefined;
const serverNextCursor = balancesResult?.nextCursor ?? null;
const hasMore = Boolean(balancesResult?.hasMore ?? false);
const summary = summaryData ?? null;
const isLoadingRows = isLoadingBalances;
const isErrorRows = isErrorBalances;
const isFetching = isFetchingBalances || isFetchingSummary;
// Reset pagination when filters change
useEffect(() => {
setCursorStack([null]);
setCursorIndex(0);
}, [staffId, startDate, endDate, npiProviderId]);
const handlePrev = useCallback(() => {
setCursorIndex((i) => Math.max(0, i - 1));
}, []);
const handleNext = useCallback(() => {
const idx = cursorIndex;
const isLastKnown = idx === cursorStack.length - 1;
if (isLastKnown) {
if (serverNextCursor && serverNextCursor !== currentCursor && balances.length > 0) {
setCursorStack((s) => [...s, serverNextCursor]);
setCursorIndex((i) => i + 1);
// React Query will fetch automatically because queryKey includes currentCursor
}
}
else {
setCursorIndex((i) => i + 1);
}
}, [cursorIndex, cursorStack.length, serverNextCursor, balances, currentCursor]);
// Map server rows to GenericRow
const genericRows = balances.map((r) => {
const totalCharges = Number(r.totalCharges ?? 0);
const totalPayments = Number(r.totalPayments ?? 0);
const currentBalance = Number(r.currentBalance ?? 0);
const name = `${r.firstName ?? ""} ${r.lastName ?? ""}`.trim() || "Unknown";
return {
id: String(r.patientId),
name,
currentBalance,
totalCharges,
totalPayments,
};
});
return (<div>
<div className="mb-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-sm text-gray-700 block mb-1 ml-2">
Select Doctor
</label>
<Select value={staffId || undefined} onValueChange={(v) => setStaffId(v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a doctor"/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{staffs?.map((s) => (<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
{/* Summary card (time-window based) */}
{staffId && (<div className="mb-4">
<Card className="pt-4 pb-4">
<CardContent>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-gray-800">
Doctor summary
</h2>
<p className="text-sm text-gray-500">
Data covers the selected time frame
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">
{summary ? Number(summary.totalPatients ?? 0) : "—"}
</div>
<p className="text-sm text-gray-600">
Total Patients (in window)
</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-red-600">
{summary ? Number(summary.patientsWithBalance ?? 0) : "—"}
</div>
<p className="text-sm text-gray-600">With Balance</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-green-600">
{summary
? Math.max(0, Number(summary.totalPatients ?? 0) -
Number(summary.patientsWithBalance ?? 0))
: "—"}
</div>
<p className="text-sm text-gray-600">Zero Balance</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-orange-600">
{summary
? fmtCurrency(Number(summary.totalOutstanding ?? 0))
: "—"}
</div>
<p className="text-sm text-gray-600">Outstanding</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-purple-600">
{summary
? fmtCurrency(Number(summary.totalCollected ?? 0))
: "—"}
</div>
<p className="text-sm text-gray-600">Collected</p>
</div>
</div>
</CardContent>
</Card>
</div>)}
{/* List (shows all patients under doctor but per-row totals are time-filtered) */}
{!staffId ? (<div className="text-sm text-gray-600">
Please select a doctor to load collections.
</div>) : (<PatientsBalancesList rows={genericRows} reportType="collections_by_doctor" loading={isLoadingRows || isFetching} error={isErrorRows
? "Failed to load collections for the selected doctor/date range."
: false} emptyMessage="No collection data for the selected doctor/date range." pageIndex={pageIndex} perPage={perPage} total={totalCount} onPrev={handlePrev} onNext={handleNext} hasPrev={cursorIndex > 0} hasNext={hasMore} headerRight={<ExportReportButton reportType="collections_by_doctor" from={startDate} to={endDate} staffId={Number(staffId)} className="mr-2"/>}/>)}
</div>);
}

View File

@@ -0,0 +1,393 @@
import React, { useEffect, useRef, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { DateInput } from "@/components/ui/dateInput";
import { formatLocalDate, parseLocalDate, formatDateToHumanReadable } from "@/utils/dateUtils";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { DollarSign } from "lucide-react";
function fmt(n) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
}
export default function CommissionSection() {
const { toast } = useToast();
const printRef = useRef(null);
// Filters
const [fromDate, setFromDate] = useState(() => {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().split("T")[0] ?? "";
});
const [toDate, setToDate] = useState(() => new Date().toISOString().split("T")[0] ?? "");
const [selectedProviderId, setSelectedProviderId] = useState(null);
// Selection
const [selectedIds, setSelectedIds] = useState(new Set());
// Pay modal
const [showModal, setShowModal] = useState(false);
const [commissionAmount, setCommissionAmount] = useState("");
const [notes, setNotes] = useState("");
// Providers
const { data: providers = [] } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
return res.json();
},
staleTime: 60_000,
});
// Eligible payments
const { data: payments = [], isLoading, isError, } = useQuery({
queryKey: ["/api/commissions/eligible", selectedProviderId, fromDate, toDate],
queryFn: async () => {
const params = new URLSearchParams();
params.set("npiProviderId", String(selectedProviderId));
if (fromDate)
params.set("from", fromDate);
if (toDate)
params.set("to", toDate);
const res = await apiRequest("GET", `/api/commissions/eligible?${params}`);
if (!res.ok)
throw new Error("Failed to fetch eligible payments");
return res.json();
},
enabled: !!selectedProviderId,
});
// Past batches
const { data: batches = [] } = useQuery({
queryKey: ["/api/commissions/batches", selectedProviderId],
queryFn: async () => {
const params = selectedProviderId
? `?npiProviderId=${selectedProviderId}`
: "";
const res = await apiRequest("GET", `/api/commissions/batches${params}`);
if (!res.ok)
throw new Error("Failed to fetch batches");
return res.json();
},
});
// Reset selection when provider/dates change
useEffect(() => {
setSelectedIds(new Set());
}, [selectedProviderId, fromDate, toDate]);
// Create commission batch mutation
const createMutation = useMutation({
mutationFn: async (payload) => {
const res = await apiRequest("POST", "/api/commissions", payload);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message ?? "Failed to save commission");
}
return res.json();
},
onSuccess: () => {
toast({ title: "Commission saved", description: "Payments have been marked as commissioned." });
setShowModal(false);
setSelectedIds(new Set());
queryClient.invalidateQueries({ queryKey: ["/api/commissions/eligible"] });
queryClient.invalidateQueries({ queryKey: ["/api/commissions/batches"] });
},
onError: (err) => {
toast({ title: "Error", description: err?.message ?? "Failed to save", variant: "destructive" });
},
});
const selectedPayments = payments.filter((p) => selectedIds.has(p.id));
const totalCollection = selectedPayments.reduce((s, p) => s + p.collectionAmount, 0);
// Sync commission amount when selection changes
useEffect(() => {
setCommissionAmount(totalCollection.toFixed(2));
}, [totalCollection]);
const allSelected = payments.length > 0 && selectedIds.size === payments.length;
const someSelected = selectedIds.size > 0 && !allSelected;
const toggleAll = () => {
if (allSelected) {
setSelectedIds(new Set());
}
else {
setSelectedIds(new Set(payments.map((p) => p.id)));
}
};
const toggleOne = (id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const handlePrint = () => {
const provider = providers.find((p) => p.id === selectedProviderId);
const win = window.open("", "_blank");
if (!win)
return;
win.document.write(`
<html><head><title>Commission Summary</title>
<style>
body { font-family: Arial, sans-serif; padding: 24px; font-size: 13px; }
h1 { font-size: 18px; margin-bottom: 4px; }
p { margin: 2px 0; color: #555; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th { background: #f3f4f6; text-align: left; padding: 8px; border: 1px solid #e5e7eb; }
td { padding: 8px; border: 1px solid #e5e7eb; }
.total { font-weight: bold; margin-top: 16px; font-size: 15px; }
.footer { margin-top: 24px; color: #888; font-size: 11px; }
</style></head><body>
<h1>Commission Summary — ${provider?.providerName ?? "Provider"}</h1>
<p>Date Range: ${fromDate || "—"} to ${toDate || "—"}</p>
<p>Generated: ${new Date().toLocaleDateString()}</p>
<table>
<thead><tr>
<th>#</th><th>Claim / Source</th><th>Patient</th>
<th>Service Date</th><th>MH Paid</th><th>Copayment</th><th>Collection</th>
</tr></thead>
<tbody>
${selectedPayments
.map((p, i) => `<tr>
<td>${i + 1}</td>
<td>${p.claimNumber ?? (p.isOcr ? "PDF Import" : "—")}</td>
<td>${p.patientName}</td>
<td>${p.serviceDate ? new Date(p.serviceDate).toLocaleDateString() : "—"}</td>
<td>${fmt(p.mhPaidAmount)}</td>
<td>${fmt(p.copayment)}</td>
<td>${fmt(p.collectionAmount)}</td>
</tr>`)
.join("")}
</tbody>
</table>
<p class="total">Total Collection: ${fmt(totalCollection)}</p>
<p class="total">Commission Amount: ${fmt(Number(commissionAmount) || 0)}</p>
${notes ? `<p style="margin-top:12px"><b>Notes:</b> ${notes}</p>` : ""}
<p class="footer">Summit Dental Care — Commission Record</p>
</body></html>
`);
win.document.close();
win.print();
};
const handleSave = () => {
if (!selectedProviderId || selectedPayments.length === 0)
return;
const amount = Number(commissionAmount);
if (isNaN(amount) || amount < 0) {
toast({ title: "Invalid amount", description: "Enter a valid commission amount.", variant: "destructive" });
return;
}
createMutation.mutate({
npiProviderId: selectedProviderId,
paymentIds: selectedPayments.map((p) => p.id),
totalCollection,
commissionAmount: amount,
notes: notes.trim() || undefined,
});
};
const fromDateObj = fromDate ? (() => { try {
return parseLocalDate(fromDate);
}
catch {
return null;
} })() : null;
const toDateObj = toDate ? (() => { try {
return parseLocalDate(toDate);
}
catch {
return null;
} })() : null;
return (<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5"/> Commission
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<DateInput label="Start Date" value={fromDateObj} onChange={(d) => setFromDate(d ? formatLocalDate(d) : "")} disableFuture/>
<DateInput label="End Date" value={toDateObj} onChange={(d) => setToDate(d ? formatLocalDate(d) : "")} disableFuture/>
<div className="space-y-2">
<Label>Provider</Label>
<Select value={selectedProviderId?.toString() ?? ""} onValueChange={(v) => setSelectedProviderId(Number(v))}>
<SelectTrigger>
<SelectValue placeholder="Select a provider"/>
</SelectTrigger>
<SelectContent>
{providers.map((p) => (<SelectItem key={p.id} value={p.id.toString()}>
{p.providerName}
</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
{/* Eligible payments table */}
{selectedProviderId && (<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm text-gray-500">
{isLoading
? "Loading…"
: `${payments.length} eligible payment(s) — not yet commissioned`}
</p>
{selectedIds.size > 0 && (<Button size="sm" onClick={() => setShowModal(true)} className="bg-green-600 hover:bg-green-700 text-white">
Pay Commission ({selectedIds.size} selected {fmt(totalCollection)})
</Button>)}
</div>
{isError && (<p className="text-sm text-red-500">Failed to load payments.</p>)}
{!isLoading && !isError && payments.length === 0 && (<p className="text-sm text-gray-400 py-4 text-center">
No uncommissioned payments found for this provider and date range.
</p>)}
{payments.length > 0 && (<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left w-10">
<Checkbox checked={allSelected} data-state={someSelected ? "indeterminate" : undefined} onCheckedChange={toggleAll}/>
</th>
<th className="px-3 py-2 text-left">Claim / Source</th>
<th className="px-3 py-2 text-left">Patient</th>
<th className="px-3 py-2 text-left">Service Date</th>
<th className="px-3 py-2 text-right">MH Paid</th>
<th className="px-3 py-2 text-right">Copayment</th>
<th className="px-3 py-2 text-right font-semibold">Collection</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (<tr key={p.id} className={`border-t border-gray-100 hover:bg-gray-50 cursor-pointer ${selectedIds.has(p.id) ? "bg-green-50" : ""}`} onClick={() => toggleOne(p.id)}>
<td className="px-3 py-2">
<Checkbox checked={selectedIds.has(p.id)} onCheckedChange={() => toggleOne(p.id)} onClick={(e) => e.stopPropagation()}/>
</td>
<td className="px-3 py-2 font-mono text-xs">
{p.claimNumber ?? (p.isOcr ? (<span className="bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded text-xs">PDF Import</span>) : "—")}
</td>
<td className="px-3 py-2">{p.patientName}</td>
<td className="px-3 py-2 text-gray-500">
{p.serviceDate
? new Date(p.serviceDate).toLocaleDateString()
: "—"}
</td>
<td className="px-3 py-2 text-right text-green-700">{fmt(p.mhPaidAmount)}</td>
<td className="px-3 py-2 text-right text-blue-700">{fmt(p.copayment)}</td>
<td className="px-3 py-2 text-right font-semibold">{fmt(p.collectionAmount)}</td>
</tr>))}
</tbody>
{selectedIds.size > 0 && (<tfoot className="bg-gray-50 border-t-2 border-gray-200">
<tr>
<td colSpan={6} className="px-3 py-2 text-right font-semibold text-gray-700">
Selected Total ({selectedIds.size} items):
</td>
<td className="px-3 py-2 text-right font-bold text-green-700 text-base">
{fmt(totalCollection)}
</td>
</tr>
</tfoot>)}
</table>
</div>)}
</div>)}
{/* Past commissions */}
{batches.length > 0 && (<div>
<h4 className="text-sm font-semibold text-gray-700 mb-2">Past Commission Batches</h4>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left">Date Paid</th>
<th className="px-3 py-2 text-left">Provider</th>
<th className="px-3 py-2 text-right">Claims</th>
<th className="px-3 py-2 text-right">Total Collection</th>
<th className="px-3 py-2 text-right">Commission Paid</th>
<th className="px-3 py-2 text-left">Notes</th>
</tr>
</thead>
<tbody>
{batches.map((b) => (<tr key={b.id} className="border-t border-gray-100">
<td className="px-3 py-2">{formatDateToHumanReadable(b.createdAt)}</td>
<td className="px-3 py-2">{b.providerName}</td>
<td className="px-3 py-2 text-right">{b.itemCount}</td>
<td className="px-3 py-2 text-right">{fmt(b.totalCollection)}</td>
<td className="px-3 py-2 text-right font-semibold text-green-700">{fmt(b.commissionAmount)}</td>
<td className="px-3 py-2 text-gray-500">{b.notes ?? "—"}</td>
</tr>))}
</tbody>
</table>
</div>
</div>)}
</CardContent>
{/* Pay Commission Modal */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="sm:max-w-[680px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Pay Commission</DialogTitle>
<DialogDescription>
Review the selected payments and confirm the commission amount.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Selected rows summary */}
<div className="overflow-x-auto rounded-lg border border-gray-200 max-h-64">
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left">Claim / Source</th>
<th className="px-3 py-2 text-left">Patient</th>
<th className="px-3 py-2 text-right">MH Paid</th>
<th className="px-3 py-2 text-right">Copayment</th>
<th className="px-3 py-2 text-right font-semibold">Collection</th>
</tr>
</thead>
<tbody>
{selectedPayments.map((p) => (<tr key={p.id} className="border-t border-gray-100">
<td className="px-3 py-1.5 font-mono text-xs">
{p.claimNumber ?? (p.isOcr ? "PDF Import" : "—")}
</td>
<td className="px-3 py-1.5">{p.patientName}</td>
<td className="px-3 py-1.5 text-right">{fmt(p.mhPaidAmount)}</td>
<td className="px-3 py-1.5 text-right">{fmt(p.copayment)}</td>
<td className="px-3 py-1.5 text-right font-medium">{fmt(p.collectionAmount)}</td>
</tr>))}
</tbody>
<tfoot className="bg-gray-50 border-t-2 border-gray-200">
<tr>
<td colSpan={4} className="px-3 py-2 text-right font-semibold">Total Collection:</td>
<td className="px-3 py-2 text-right font-bold text-green-700">{fmt(totalCollection)}</td>
</tr>
</tfoot>
</table>
</div>
{/* Commission amount input */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Commission Amount ($)</Label>
<Input type="number" min="0" step="0.01" value={commissionAmount} onChange={(e) => setCommissionAmount(e.target.value)} placeholder="Enter commission amount"/>
<p className="text-xs text-gray-400">Defaults to total collection. Adjust if using a rate.</p>
</div>
<div className="space-y-1">
<Label>Notes (optional)</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="e.g. May 2026 commission @ 20%"/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={handlePrint}>
Print
</Button>
<Button variant="outline" onClick={() => setShowModal(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={createMutation.isPending} className="bg-green-600 hover:bg-green-700 text-white">
{createMutation.isPending ? "Saving…" : "Save & Mark as Commissioned"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Card>);
}

View File

@@ -0,0 +1,49 @@
import React, { useState } from "react";
import { apiRequest } from "@/lib/queryClient";
export default function ExportReportButton({ reportType, from, to, staffId, className, labelCsv = "Download CSV", }) {
const [loading, setLoading] = useState(false);
async function downloadCsv() {
setLoading(true);
try {
const params = new URLSearchParams();
params.set("type", reportType);
if (from)
params.set("from", from);
if (to)
params.set("to", to);
if (staffId)
params.set("staffId", String(staffId));
params.set("format", "csv"); // server expects format=csv
const url = `/api/export-payments-reports/export?${params.toString()}`;
// Use apiRequest for consistent auth headers/cookies
const res = await apiRequest("GET", url);
if (!res.ok) {
const body = await res.text().catch(() => "Export failed");
throw new Error(body || "Export failed");
}
const blob = await res.blob();
const href = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = href;
const safeFrom = from || "all";
const safeTo = to || "all";
a.download = `${reportType}_${safeFrom}_${safeTo}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(href);
}
catch (err) {
console.error("Export CSV failed", err);
alert("Export failed: " + (err?.message ?? "unknown error"));
}
finally {
setLoading(false);
}
}
return (<div className={className ?? "flex items-center gap-2"}>
<button type="button" onClick={downloadCsv} disabled={loading} className="inline-flex items-center px-3 py-2 rounded border text-sm">
{loading ? "Preparing..." : labelCsv}
</button>
</div>);
}

View File

@@ -0,0 +1,37 @@
import React from "react";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
export default function PaginationControls({ pageIndex, perPage, total, onPrev, onNext, hasPrev, hasNext, }) {
const startItem = total === 0 ? 0 : (pageIndex - 1) * perPage + 1;
const endItem = Math.min(pageIndex * perPage, total ?? pageIndex * perPage);
return (<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground whitespace-nowrap">
{typeof total === "number"
? `Showing ${startItem}-${endItem} of ${total}`
: `Page ${pageIndex}`}
</div>
<div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" onClick={(e) => {
e.preventDefault();
if (hasPrev)
onPrev();
}} className={hasPrev ? "" : "pointer-events-none opacity-50"}/>
</PaginationItem>
<div className="px-2"/>
<PaginationItem>
<PaginationNext href="#" onClick={(e) => {
e.preventDefault();
if (hasNext)
onNext();
}} className={hasNext ? "" : "pointer-events-none opacity-50"}/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>);
}

View File

@@ -0,0 +1,77 @@
import React from "react";
import { DollarSign } from "lucide-react";
import PaginationControls from "./pagination-controls";
export default function PatientsBalancesList({ rows, reportType, loading, error, emptyMessage, pageIndex = 1, // 1-based
perPage = 10, total, // optional totalCount from backend
onPrev, onNext, hasPrev, hasNext, headerRight, // optional UI node to render in header
}) {
const fmt = (v) => new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(v);
const reportTypeTitle = (rt) => {
switch (rt) {
case "patients_with_balance":
return "Patients with Outstanding Balances";
case "patients_no_balance":
return "Patients with Zero Balance";
case "monthly_collections":
return "Monthly Collections";
case "collections_by_doctor":
return "Collections by Doctor";
default:
return "Balances";
}
};
return (<div className="space-y-4">
<div className="bg-white rounded-lg border">
<div className="px-4 py-3 border-b bg-gray-50 flex items-center justify-between">
<h3 className="font-medium text-gray-900">
{reportTypeTitle(reportType)}
</h3>
{/* headerRight rendered here (if provided) */}
<div>{headerRight ?? null}</div>
</div>
<div className="divide-y min-h-[120px]">
{loading ? (<div className="p-8 text-center text-gray-600">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<div>Loading {reportType ?? "data"}</div>
</div>) : error ? (<div className="p-8 text-center text-red-600">
<div className="mb-2 font-semibold">Could not fetch data</div>
<div className="text-sm text-red-500">
{typeof error === "string"
? error
: "An error occurred while loading the report."}
</div>
</div>) : rows.length === 0 ? (<div className="p-8 text-center text-gray-500">
<DollarSign className="h-12 w-12 mx-auto mb-3 text-gray-300"/>
<p>{emptyMessage ?? "No rows for this report."}</p>
</div>) : (rows.map((r) => (<div key={String(r.id)} className="p-4 hover:bg-gray-50">
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium text-gray-900">{r.name}</h4>
<p className="text-sm text-gray-500">ID: {r.id}</p>
</div>
<div className="text-right">
<div className={`text-lg font-semibold ${r.currentBalance > 0 ? "text-red-600" : "text-green-600"}`}>
{fmt(r.currentBalance)}
</div>
<div className="text-sm text-gray-500">
Charges: {fmt(r.totalCharges)} · Collected:{" "}
{fmt(r.totalPayments)}
</div>
</div>
</div>
</div>)))}
</div>
{/* Cursor pagination footer (cursor-only) */}
<div className="bg-white px-4 py-3 border-t border-gray-200">
<PaginationControls pageIndex={pageIndex} perPage={perPage} total={total} onPrev={onPrev} onNext={onNext} hasPrev={hasPrev} hasNext={hasNext}/>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,89 @@
import React, { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import PatientsBalancesList from "./patients-balances-list";
import ExportReportButton from "./export-button";
export default function PatientsWithBalanceReport({ startDate, endDate, npiProviderId, }) {
const balancesPerPage = 10;
const [cursorStack, setCursorStack] = useState([null]);
const [cursorIndex, setCursorIndex] = useState(0);
const currentCursor = cursorStack[cursorIndex] ?? null;
const pageIndex = cursorIndex + 1; // 1-based for UI
const { data, isLoading, isError, refetch } = useQuery({
queryKey: [
"/api/payments-reports/patient-balances",
currentCursor,
balancesPerPage,
startDate,
endDate,
npiProviderId ?? "all",
],
queryFn: async () => {
const params = new URLSearchParams();
params.set("limit", String(balancesPerPage));
if (currentCursor)
params.set("cursor", currentCursor);
if (startDate)
params.set("from", startDate);
if (endDate)
params.set("to", endDate);
if (npiProviderId)
params.set("npiProviderId", String(npiProviderId));
const res = await apiRequest("GET", `/api/payments-reports/patients-with-balances?${params.toString()}`);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ message: "Failed to load patient balances" }));
throw new Error(body.message || "Failed to load patient balances");
}
return res.json();
},
enabled: true,
});
const balances = data?.balances ?? [];
const totalCount = data?.totalCount ?? undefined;
const nextCursor = data?.nextCursor ?? null;
const hasMore = data?.hasMore ?? false;
useEffect(() => {
setCursorStack([null]);
setCursorIndex(0);
refetch();
}, [startDate, endDate, npiProviderId]);
const handleNext = useCallback(() => {
const idx = cursorIndex;
const isLastKnown = idx === cursorStack.length - 1;
if (isLastKnown) {
if (nextCursor) {
setCursorStack((s) => [...s, nextCursor]);
setCursorIndex((i) => i + 1);
}
}
else {
setCursorIndex((i) => i + 1);
}
}, [cursorIndex, cursorStack.length, nextCursor]);
const handlePrev = useCallback(() => {
setCursorIndex((i) => Math.max(0, i - 1));
}, []);
const normalized = balances.map((b) => {
const currentBalance = Number(b.currentBalance ?? 0);
const totalCharges = Number(b.totalCharges ?? 0);
const totalPayments = b.totalPayments != null
? Number(b.totalPayments)
: Number(totalCharges - currentBalance);
return {
id: b.patientId,
name: `${b.firstName ?? "Unknown"} ${b.lastName ?? ""}`.trim(),
currentBalance,
totalCharges,
totalPayments,
};
});
return (<div>
<div className="mb-4">
<PatientsBalancesList rows={normalized} reportType="patients_with_balance" loading={isLoading} error={isError
? "Failed to load patient balances for the selected date range."
: false} emptyMessage="No patient balances for the selected date range." pageIndex={pageIndex} perPage={balancesPerPage} total={totalCount} onPrev={handlePrev} onNext={handleNext} hasPrev={cursorIndex > 0} hasNext={hasMore} headerRight={<ExportReportButton reportType="patients_with_balance" from={startDate} to={endDate} className="mr-2"/>}/>
</div>
</div>);
}

View File

@@ -0,0 +1,107 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Calendar } from "lucide-react";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { DateInput } from "@/components/ui/dateInput";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
export default function ReportConfig({ startDate, endDate, setStartDate, setEndDate, selectedReportType, setSelectedReportType, npiProviderId, setNpiProviderId, }) {
let startDateObj = null;
if (startDate) {
try {
startDateObj = parseLocalDate(startDate);
}
catch {
startDateObj = null;
}
}
let endDateObj = null;
if (endDate) {
try {
endDateObj = parseLocalDate(endDate);
}
catch {
endDateObj = null;
}
}
const { data: npiProviders = [] } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
return res.json();
},
staleTime: 60_000,
});
return (<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5"/> Report Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-gray-500">
Choose the report type, date range, and provider.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<DateInput label="Start Date" value={startDateObj} onChange={(d) => setStartDate(d ? formatLocalDate(d) : "")} disableFuture/>
</div>
<div>
<DateInput label="End Date" value={endDateObj} onChange={(d) => setEndDate(d ? formatLocalDate(d) : "")} disableFuture/>
</div>
<div className="space-y-2">
<Label>Provider</Label>
<Select value={npiProviderId?.toString() ?? "all"} onValueChange={(v) => setNpiProviderId(v === "all" ? null : Number(v))}>
<SelectTrigger>
<SelectValue placeholder="All Providers"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Providers</SelectItem>
{npiProviders.map((p) => (<SelectItem key={p.id} value={p.id.toString()}>
{p.providerName}
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="report-type">Report Type</Label>
<Select value={selectedReportType} onValueChange={(v) => setSelectedReportType(v)}>
<SelectTrigger>
<SelectValue placeholder="Select report type"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="patients_with_balance">
Patients with Outstanding Balance
</SelectItem>
<SelectItem value="patients_no_balance">
Patients with Zero Balance
</SelectItem>
<SelectItem value="monthly_collections">
Monthly Collections Summary
</SelectItem>
<SelectItem value="procedure_codes_by_doctor">
Procedure Codes by Doctor
</SelectItem>
<SelectItem value="payment_methods">
Payment Methods Breakdown
</SelectItem>
<SelectItem value="insurance_vs_patient_payments">
Insurance vs Patient Payments
</SelectItem>
<SelectItem value="aging_report">
Accounts Receivable Aging
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,94 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
function fmtCurrency(v) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(v);
}
export default function SummaryCards({ startDate, endDate, npiProviderId, }) {
const { data, isLoading, isError } = useQuery({
queryKey: ["/api/payments-reports/summary", startDate, endDate, npiProviderId ?? "all"],
queryFn: async () => {
const params = new URLSearchParams();
if (startDate)
params.set("from", startDate);
if (endDate)
params.set("to", endDate);
if (npiProviderId)
params.set("npiProviderId", String(npiProviderId));
const endpoint = `/api/payments-reports/summary?${params.toString()}`;
const res = await apiRequest("GET", endpoint);
if (!res.ok) {
const body = await res
.json()
.catch(() => ({ message: "Failed to load dashboard summary" }));
throw new Error(body?.message ?? "Failed to load dashboard summary");
}
return res.json();
},
enabled: Boolean(startDate && endDate),
});
const totalPatients = data?.totalPatients ?? 0;
const patientsWithBalance = data?.patientsWithBalance ?? 0;
const patientsNoBalance = Math.max(0, (data?.totalPatients ?? 0) - (data?.patientsWithBalance ?? 0));
const totalOutstanding = data?.totalOutstanding ?? 0;
const totalCollected = data?.totalCollected ?? 0;
return (<Card className="pt-4 pb-4">
<CardContent>
{/* Heading */}
<div className="mb-3">
<h2 className="text-base font-semibold text-gray-800">
Report summary
</h2>
<p className="text-sm text-gray-500">
Data covers the selected time frame
</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-4">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">
{isLoading ? "—" : totalPatients}
</div>
<p className="text-sm text-gray-600">Total Patients</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-red-600">
{isLoading ? "—" : patientsWithBalance}
</div>
<p className="text-sm text-gray-600">With Balance</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-green-600">
{isLoading ? "—" : patientsNoBalance}
</div>
<p className="text-sm text-gray-600">Zero Balance</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-orange-600">
{isLoading ? "—" : fmtCurrency(totalOutstanding)}
</div>
<p className="text-sm text-gray-600">Outstanding</p>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-purple-600">
{isLoading ? "—" : fmtCurrency(totalCollected)}
</div>
<p className="text-sm text-gray-600">Collected</p>
</div>
</div>
{isError && (<div className="mt-3 text-sm text-red-600">
Failed to load summary. Check server or network.
</div>)}
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { Eye, EyeOff } from "lucide-react";
const SITE_KEY_OPTIONS = [
{ value: "MH", label: "MassHealth (MH)" },
{ value: "DDMA", label: "Delta Dental MA (DDMA)" },
{ value: "DELTAINS", label: "Delta Dental Ins (DELTAINS)" },
{ value: "TUFTS_SCO", label: "Tufts SCO (TUFTS_SCO)" },
{ value: "UNITED_SCO", label: "United SCO / DentalHub (UNITED_SCO)" },
{ value: "CCA", label: "CCA (CCA)" },
{ value: "BCBS_MA", label: "BCBS MA (BCBS_MA)" },
];
export function CredentialForm({ onClose, userId, defaultValues }) {
const [siteKey, setSiteKey] = useState(defaultValues?.siteKey || "");
const [username, setUsername] = useState(defaultValues?.username || "");
const [password, setPassword] = useState(defaultValues?.password || "");
const [showPassword, setShowPassword] = useState(false);
const queryClient = useQueryClient();
// Create or Update Mutation inside form
const mutation = useMutation({
mutationFn: async () => {
const payload = {
siteKey: siteKey.trim(),
username: username.trim(),
password: password.trim(),
userId,
};
const url = defaultValues?.id
? `/api/insuranceCreds/${defaultValues.id}`
: "/api/insuranceCreds/";
const method = defaultValues?.id ? "PUT" : "POST";
const res = await apiRequest(method, url, payload);
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message || "Failed to save credential");
}
return res.json();
},
onSuccess: () => {
toast({
title: `Credential ${defaultValues?.id ? "updated" : "created"}.`,
});
queryClient.invalidateQueries({ queryKey: ["/api/insuranceCreds/"] });
onClose();
},
onError: (error) => {
toast({
title: "Error",
description: error.message || "Unknown error",
variant: "destructive",
});
},
});
// Reset form on defaultValues change (edit mode)
useEffect(() => {
setSiteKey(defaultValues?.siteKey || "");
setUsername(defaultValues?.username || "");
setPassword(defaultValues?.password || "");
}, [defaultValues]);
const handleSubmit = (e) => {
e.preventDefault();
if (!siteKey || !username || !password) {
toast({
title: "Error",
description: "All fields are required.",
variant: "destructive",
});
return;
}
mutation.mutate();
};
return (<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
<h2 className="text-lg font-bold mb-4">
{defaultValues?.id ? "Edit Credential" : "Create Credential"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">Insurance</label>
<select value={siteKey} onChange={(e) => setSiteKey(e.target.value)} className="mt-1 p-2 border rounded w-full bg-white">
<option value=""> Select insurance </option>
{SITE_KEY_OPTIONS.map((opt) => (<option key={opt.value} value={opt.value}>
{opt.label}
</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium">Username</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1 p-2 border rounded w-full"/>
</div>
<div>
<label className="block text-sm font-medium">Password</label>
<div className="relative mt-1">
<input type={showPassword ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} className="p-2 border rounded w-full pr-10"/>
<button type="button" onClick={() => setShowPassword((prev) => !prev)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showPassword ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
</button>
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="text-gray-600 hover:underline" disabled={mutation.isPending}>
Cancel
</button>
<button type="submit" disabled={mutation.isPending} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending
? defaultValues?.id
? "Updating..."
: "Creating..."
: defaultValues?.id
? "Update"
: "Create"}
</button>
</div>
</form>
</div>
</div>);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, CalendarX } from "lucide-react";
const SUPPORTED_SMS_VARS = [
"{firstName}", "{officeName}", "{appointmentDate}", "{appointmentTime}", "{date}", "{time}",
];
const DEFAULTS = {
reminderSms: "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please reply YES to confirm or NO to reschedule. Thank you!",
reminderGreeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply to your message at any time you need.",
newPatientGreeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?",
generalFallback: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?",
rescheduleGreeting: "It is understandable! When would you like to reschedule your appointment?",
};
function preview(text, officeName) {
return text.replace(/\{officeName\}/g, officeName || "your dental office");
}
/** Returns any {variable} tokens in `text` that are not in the supported list. */
function unsupportedVars(text) {
const found = text.match(/\{[^}]+\}/g) ?? [];
return [...new Set(found)].filter((v) => !SUPPORTED_SMS_VARS.includes(v));
}
/** True if text starts with a self-introduction ("Hi! My name is Lisa…"). */
function hasIntroPattern(text) {
return /^(Hi[!,]?\s*)?(My name is|I'?m|I am)\s+/i.test(text.trim());
}
export function AiChatTemplatesCard() {
const { toast } = useToast();
const [reminderSms, setReminderSms] = useState(DEFAULTS.reminderSms);
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
const [rescheduleGreeting, setRescheduleGreeting] = useState(DEFAULTS.rescheduleGreeting);
const initialized = useRef(false);
const { data: officeContact } = useQuery({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok)
return null;
return res.json();
},
staleTime: 60_000,
});
const { data: templates, isLoading } = useQuery({
queryKey: ["/api/ai/chat-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/chat-templates");
if (!res.ok)
throw new Error("Failed to load templates");
return res.json();
},
staleTime: Infinity, // never silently refetch and overwrite user edits
refetchOnWindowFocus: false,
});
// Seed state from server on first successful load only
useEffect(() => {
if (templates && !initialized.current) {
initialized.current = true;
setReminderSms(templates.reminderSms || DEFAULTS.reminderSms);
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
setRescheduleGreeting(templates.rescheduleGreeting || DEFAULTS.rescheduleGreeting);
}
}, [templates]);
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/ai/chat-templates", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save templates");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/chat-templates"] });
toast({ title: "Templates saved", description: "AI chat templates have been updated." });
},
onError: (err) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const handleSubmit = (e) => {
e.preventDefault();
saveMutation.mutate({
reminderSms: reminderSms.trim() || DEFAULTS.reminderSms,
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
rescheduleGreeting: rescheduleGreeting.trim() || DEFAULTS.rescheduleGreeting,
});
};
const officeName = officeContact?.officeName?.trim() || "";
const templates_list = [
{
key: "reminderSms",
icon: <CalendarCheck className="h-4 w-4 text-primary"/>,
label: "Reminder SMS Text",
description: "Outgoing text sent from the Schedule page. Supported variables: {firstName}, {officeName}, {appointmentDate}, {appointmentTime}, {date}, {time}. Any other {variable} will be sent as plain text.",
value: reminderSms,
onChange: setReminderSms,
placeholder: DEFAULTS.reminderSms,
},
{
key: "reminder",
icon: <CalendarCheck className="h-4 w-4 text-primary"/>,
label: "Appointment Reminder Reply",
description: "Sent when the AI introduces itself after the office sends an appointment reminder.",
value: reminderGreeting,
onChange: setReminderGreeting,
placeholder: DEFAULTS.reminderGreeting,
},
{
key: "newPatient",
icon: <UserPlus className="h-4 w-4 text-primary"/>,
label: "New Patient Greeting",
description: "Sent when a new patient texts in for the first time.",
value: newPatientGreeting,
onChange: setNewPatientGreeting,
placeholder: DEFAULTS.newPatientGreeting,
},
{
key: "general",
icon: <MessageCircle className="h-4 w-4 text-primary"/>,
label: "General Fallback",
description: "Used when the AI cannot determine the context of the patient's message.",
value: generalFallback,
onChange: setGeneralFallback,
placeholder: DEFAULTS.generalFallback,
},
{
key: "reschedule",
icon: <CalendarX className="h-4 w-4 text-primary"/>,
label: "Reschedule Patients",
description: "Sent when the office initiates a reschedule flow for a patient.",
value: rescheduleGreeting,
onChange: setRescheduleGreeting,
placeholder: DEFAULTS.rescheduleGreeting,
},
];
return (<Card>
<CardContent className="py-6 space-y-6">
{/* Header */}
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary"/>
<h3 className="text-lg font-semibold">AI Chat Templates</h3>
</div>
<p className="text-sm text-muted-foreground">
Customize the reminder SMS and AI reply templates. Available variables:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{firstName}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{appointmentDate}"}</code>{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{appointmentTime}"}</code>{" "}
replaced automatically when reminders are sent.
</p>
{/* Office name hint */}
{officeName && (<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
<Info className="h-3.5 w-3.5 flex-shrink-0"/>
<span>
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
<span className="font-medium text-foreground">"{officeName}"</span>
</span>
</div>)}
{isLoading ? (<p className="text-sm text-muted-foreground">Loading templates...</p>) : (<form onSubmit={handleSubmit} className="space-y-6">
{templates_list.map((t) => {
const badVars = t.key === "reminderSms" ? unsupportedVars(t.value) : [];
const hasIntro = t.key === "reschedule" && hasIntroPattern(t.value);
const hasWarn = badVars.length > 0 || hasIntro;
return (<div key={t.key} className="space-y-2">
<div className="flex items-center gap-2">
{t.icon}
<span className="text-sm font-medium">{t.label}</span>
</div>
<p className="text-xs text-muted-foreground">{t.description}</p>
<Textarea value={t.value} onChange={(e) => t.onChange(e.target.value)} placeholder={t.placeholder} rows={3} className={`text-sm resize-none ${hasWarn ? "border-amber-400 focus-visible:ring-amber-400" : ""}`}/>
{/* Unsupported variable warning */}
{badVars.length > 0 && (<div className="flex items-start gap-2 rounded-md bg-amber-50 border border-amber-300 px-3 py-2 text-xs text-amber-800">
<span className="mt-0.5"></span>
<span>
<strong>Unsupported variable{badVars.length > 1 ? "s" : ""}:</strong>{" "}
{badVars.join(", ")} {badVars.length > 1 ? "these" : "this"} will be sent as plain text to patients.
Supported: {SUPPORTED_SMS_VARS.join(", ")}.
</span>
</div>)}
{/* Self-intro warning for reschedule greeting */}
{hasIntro && (<div className="flex items-start gap-2 rounded-md bg-amber-50 border border-amber-300 px-3 py-2 text-xs text-amber-800">
<span className="mt-0.5"></span>
<span>
This template starts with a self-introduction ("Hi! My name is Lisa…").
The AI introduction is already sent as a <strong>separate first message</strong>
this template is only used as the <strong>second message</strong> (the intent response).
Remove the intro to avoid the patient receiving "My name is Lisa…" twice.
</span>
</div>)}
{/* Live preview */}
{officeName && t.value.includes("{officeName}") && (<p className="text-xs text-muted-foreground italic pl-1">
Preview: {preview(t.value, officeName)}
</p>)}
</div>);
})}
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={saveMutation.isPending} className="bg-teal-600 hover:bg-teal-700 text-white">
{saveMutation.isPending ? "Saving..." : "Save Templates"}
</Button>
<Button type="button" variant="ghost" className="text-xs text-muted-foreground" onClick={() => {
setReminderSms(DEFAULTS.reminderSms);
setReminderGreeting(DEFAULTS.reminderGreeting);
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
setGeneralFallback(DEFAULTS.generalFallback);
setRescheduleGreeting(DEFAULTS.rescheduleGreeting);
}}>
Reset to defaults
</Button>
</div>
</form>)}
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,235 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Eye, EyeOff, CheckCircle } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
function ApiKeySection({ title, description, apiKey, enabled, isConfigured, onSave, onToggle, isSaving, isToggling, modelOptions, selectedModel, onModelChange, }) {
const [localKey, setLocalKey] = useState(apiKey);
const [showKey, setShowKey] = useState(false);
useEffect(() => {
setLocalKey(apiKey);
}, [apiKey]);
return (<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{title}</h3>
{isConfigured && (<span className="flex items-center gap-1 text-xs text-green-600 font-medium">
<CheckCircle className="h-3.5 w-3.5"/> Configured
</span>)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{enabled ? "On" : "Off"}</span>
<Switch checked={enabled} onCheckedChange={onToggle} disabled={isToggling}/>
</div>
</div>
<p className="text-sm text-gray-500">{description}</p>
{modelOptions && onModelChange && (<div>
<label className="block text-sm font-medium mb-1">Model</label>
<select value={selectedModel} onChange={(e) => onModelChange(e.target.value)} className="p-2 border rounded w-full text-sm bg-white">
{modelOptions.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
</select>
</div>)}
<div>
<label className="block text-sm font-medium">API Key</label>
<div className="relative mt-1">
<input type={showKey ? "text" : "password"} value={localKey} onChange={(e) => setLocalKey(e.target.value)} className="p-2 border rounded w-full pr-10 font-mono text-sm" placeholder="••••••••••••••••••••••••••••••••••••••"/>
<button type="button" onClick={() => setShowKey((v) => !v)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showKey ? <EyeOff size={16}/> : <Eye size={16}/>}
</button>
</div>
</div>
<button onClick={() => { if (localKey.trim())
onSave(localKey.trim()); }} className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm" disabled={isSaving || !localKey.trim()}>
{isSaving ? "Saving..." : "Save"}
</button>
</div>);
}
export function AiSettingsCard() {
const { toast } = useToast();
const [apiKey, setApiKey] = useState("");
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [aiEnabled, setAiEnabled] = useState(true);
const { data: settings, isLoading } = useQuery({
queryKey: ["/api/ai/settings"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/settings");
if (!res.ok)
return null;
return res.json();
},
});
useEffect(() => {
if (settings) {
setApiKey(settings.apiKey ?? "");
setAiEnabled(settings.aiEnabled ?? true);
}
}, [settings]);
// Google AI save
const googleSaveMutation = useMutation({
mutationFn: async (key) => {
const res = await apiRequest("PUT", "/api/ai/settings", { apiKey: key });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed to save");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
toast({ title: "Saved", description: "Google AI API key saved." });
},
onError: (err) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
// Google AI toggle
const googleToggleMutation = useMutation({
mutationFn: async (enabled) => {
const res = await apiRequest("PUT", "/api/ai/enabled", { aiEnabled: enabled });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed");
return res.json();
},
onSuccess: (_d, enabled) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
toast({ title: enabled ? "Google AI Enabled" : "Google AI Disabled" });
},
onError: (err) => {
setAiEnabled((v) => !v);
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
// Provider key save
const providerKeyMutation = useMutation({
mutationFn: async ({ provider, apiKey }) => {
const res = await apiRequest("PUT", "/api/ai/provider-key", { provider, apiKey });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed");
return { provider };
},
onSuccess: ({ provider }) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
const names = { openAi: "OpenAI", claudeAi: "Claude AI", dentalMgmt: "DentalManagement AI" };
toast({ title: "Saved", description: `${names[provider]} API key saved.` });
},
onError: (err) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
// Provider model change
const providerModelMutation = useMutation({
mutationFn: async ({ provider, model }) => {
const res = await apiRequest("PUT", "/api/ai/provider-model", { provider, model });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed");
return { provider, model };
},
onSuccess: ({ provider, model }) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
const names = { claudeAi: "Claude AI", openAi: "OpenAI" };
toast({ title: "Model updated", description: `${names[provider]} model set to ${model}.` });
},
onError: (err) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
// Provider toggle
const providerToggleMutation = useMutation({
mutationFn: async ({ provider, enabled }) => {
const res = await apiRequest("PUT", "/api/ai/provider-enabled", { provider, enabled });
if (!res.ok)
throw new Error((await res.json().catch(() => null))?.message || "Failed");
return { provider, enabled };
},
onSuccess: ({ provider, enabled }) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
const names = { openAi: "OpenAI", claudeAi: "Claude AI", dentalMgmt: "DentalManagement AI" };
toast({ title: `${names[provider]} ${enabled ? "Enabled" : "Disabled"}` });
},
onError: (err) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
const CLAUDE_MODELS = [
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 — Fast & affordable" },
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 — Balanced" },
{ value: "claude-opus-4-8", label: "Claude Opus 4.8 — Most capable" },
];
const OPENAI_MODELS = [
{ value: "gpt-5.2", label: "GPT-5.2 — Standard (recommended)" },
{ value: "gpt-5.2-pro", label: "GPT-5.2 Pro — Professional grade" },
{ value: "gpt-5.4", label: "GPT-5.4 — Previous gen, high quality" },
{ value: "gpt-5.4-pro", label: "GPT-5.4 Pro — Previous gen pro" },
{ value: "gpt-5.5", label: "GPT-5.5 — Flagship, complex tasks" },
{ value: "gpt-5.5-pro", label: "GPT-5.5 Pro — Flagship professional" },
];
const GOOGLE_MODELS = [
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash — Fast & balanced (recommended)" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro — High capability, 1M context" },
{ value: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite — Optimized for speed/cost" },
{ value: "gemini-3.5-flash", label: "Gemini 3.5 Flash — Latest frontier" },
];
if (isLoading) {
return <Card><CardContent className="py-6"><p className="text-sm text-gray-400">Loading...</p></CardContent></Card>;
}
return (<Card>
<CardContent className="py-6 space-y-6">
{/* Google AI */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Google AI Settings</h3>
{!!settings?.apiKey && (<span className="flex items-center gap-1 text-xs text-green-600 font-medium">
<CheckCircle className="h-3.5 w-3.5"/> Configured
</span>)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{aiEnabled ? "On" : "Off"}</span>
<Switch checked={aiEnabled} onCheckedChange={(v) => { setAiEnabled(v); googleToggleMutation.mutate(v); }} disabled={googleToggleMutation.isPending}/>
</div>
</div>
<p className="text-sm text-gray-500">
Enter your Google AI Studio API key to enable AI-powered SMS replies.
</p>
<div>
<label className="block text-sm font-medium mb-1">Model</label>
<select value={settings?.googleAiModel ?? "gemini-2.5-flash"} onChange={(e) => providerModelMutation.mutate({ provider: "googleAi", model: e.target.value })} className="p-2 border rounded w-full text-sm bg-white">
{GOOGLE_MODELS.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium">Google AI Studio API Key</label>
<div className="relative mt-1">
<input type={showGoogleKey ? "text" : "password"} value={apiKey} onChange={(e) => setApiKey(e.target.value)} className="p-2 border rounded w-full pr-10 font-mono text-sm" placeholder="AIza••••••••••••••••••••••••••••••••••••••"/>
<button type="button" onClick={() => setShowGoogleKey((v) => !v)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showGoogleKey ? <EyeOff size={16}/> : <Eye size={16}/>}
</button>
</div>
</div>
<button onClick={() => { if (apiKey.trim())
googleSaveMutation.mutate(apiKey.trim()); }} className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm" disabled={googleSaveMutation.isPending || !apiKey.trim()}>
{googleSaveMutation.isPending ? "Saving..." : "Save"}
</button>
{!!settings?.apiKey && (<div className="mt-2 p-3 bg-gray-50 rounded border text-xs text-gray-600 space-y-1">
<p className="font-medium text-gray-700">AI Auto-Reply Rules</p>
<p><span className="text-green-600 font-medium">Patient replies "Yes"</span> AI sends a thank-you confirmation</p>
<p><span className="text-red-500 font-medium">Patient replies "No" / "Not available"</span> AI notifies them an assistant will follow up</p>
</div>)}
</div>
<Separator />
{/* OpenAI */}
<ApiKeySection title="OpenAI Settings" description="Enter your OpenAI API key to enable OpenAI-powered features." apiKey={settings?.openAiKey ?? ""} enabled={settings?.openAiEnabled ?? false} isConfigured={!!settings?.openAiKey} onSave={(key) => providerKeyMutation.mutate({ provider: "openAi", apiKey: key })} onToggle={(v) => providerToggleMutation.mutate({ provider: "openAi", enabled: v })} isSaving={providerKeyMutation.isPending && providerKeyMutation.variables?.provider === "openAi"} isToggling={providerToggleMutation.isPending && providerToggleMutation.variables?.provider === "openAi"} modelOptions={OPENAI_MODELS} selectedModel={settings?.openAiModel ?? "gpt-4o-mini"} onModelChange={(model) => providerModelMutation.mutate({ provider: "openAi", model })}/>
<Separator />
{/* Claude AI */}
<ApiKeySection title="Claude AI Settings" description="Enter your Anthropic Claude API key to enable Claude-powered features." apiKey={settings?.claudeAiKey ?? ""} enabled={settings?.claudeAiEnabled ?? false} isConfigured={!!settings?.claudeAiKey} onSave={(key) => providerKeyMutation.mutate({ provider: "claudeAi", apiKey: key })} onToggle={(v) => providerToggleMutation.mutate({ provider: "claudeAi", enabled: v })} isSaving={providerKeyMutation.isPending && providerKeyMutation.variables?.provider === "claudeAi"} isToggling={providerToggleMutation.isPending && providerToggleMutation.variables?.provider === "claudeAi"} modelOptions={CLAUDE_MODELS} selectedModel={settings?.claudeAiModel ?? "claude-haiku-4-5-20251001"} onModelChange={(model) => providerModelMutation.mutate({ provider: "claudeAi", model })}/>
<Separator />
{/* DentalManagement AI */}
<ApiKeySection title="DentalManagement AI Settings" description="Enter your DentalManagement AI API key to enable DentalManagement-powered features." apiKey={settings?.dentalMgmtKey ?? ""} enabled={settings?.dentalMgmtEnabled ?? false} isConfigured={!!settings?.dentalMgmtKey} onSave={(key) => providerKeyMutation.mutate({ provider: "dentalMgmt", apiKey: key })} onToggle={(v) => providerToggleMutation.mutate({ provider: "dentalMgmt", enabled: v })} isSaving={providerKeyMutation.isPending && providerKeyMutation.variables?.provider === "dentalMgmt"} isToggling={providerToggleMutation.isPending && providerToggleMutation.variables?.provider === "dentalMgmt"}/>
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,197 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
import { Plus, Pencil, Trash2, X, Check } from "lucide-react";
const EMPTY_FORM = { name: "", phoneNumber: "" };
export function InsuranceContactCard() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState(null);
const { data: contacts = [], isLoading } = useQuery({
queryKey: ["/api/insurance-contacts"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/insurance-contacts");
if (!res.ok)
throw new Error("Failed to fetch");
return res.json();
},
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["/api/insurance-contacts"] });
const createMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("POST", "/api/insurance-contacts", data);
if (!res.ok) {
const e = await res.json().catch(() => null);
throw new Error(e?.message || "Failed to save");
}
return res.json();
},
onSuccess: () => {
invalidate();
setShowForm(false);
setForm(EMPTY_FORM);
toast({ title: "Insurance contact added" });
},
onError: (e) => toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }) => {
const res = await apiRequest("PUT", `/api/insurance-contacts/${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();
setEditingId(null);
setForm(EMPTY_FORM);
toast({ title: "Insurance contact updated" });
},
onError: (e) => toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const deleteMutation = useMutation({
mutationFn: async (id) => {
const res = await apiRequest("DELETE", `/api/insurance-contacts/${id}`);
if (!res.ok)
throw new Error("Failed to delete");
},
onSuccess: () => {
invalidate();
setDeleteTarget(null);
toast({ title: "Insurance contact deleted" });
},
onError: (e) => toast({ title: "Error", description: e.message, variant: "destructive" }),
});
const openAdd = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setShowForm(true);
};
const openEdit = (c) => {
setShowForm(false);
setEditingId(c.id);
setForm({ name: c.name, phoneNumber: c.phoneNumber ?? "" });
};
const cancelEdit = () => { setEditingId(null); setForm(EMPTY_FORM); };
const cancelAdd = () => { setShowForm(false); setForm(EMPTY_FORM); };
const handleSave = () => {
if (!form.name.trim()) {
toast({ title: "Name is required", variant: "destructive" });
return;
}
if (editingId !== null) {
updateMutation.mutate({ id: editingId, data: form });
}
else {
createMutation.mutate(form);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (<div className="bg-white shadow rounded-lg overflow-hidden">
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<div>
<h2 className="text-lg font-semibold text-gray-900">Insurance Contacts</h2>
<p className="text-sm text-gray-500 mt-0.5">Phone numbers for insurance companies</p>
</div>
<Button onClick={openAdd} size="sm">
<Plus className="h-4 w-4 mr-1"/> Add Contact
</Button>
</div>
{/* Add form */}
{showForm && (<div className="p-4 border-b bg-gray-50">
<p className="text-sm font-medium text-gray-700 mb-3">New Insurance Contact</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Company Name *</label>
<Input placeholder="e.g. Delta MA" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} className="h-9 text-sm"/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Phone Number</label>
<Input placeholder="e.g. (800) 555-0100" value={form.phoneNumber} onChange={(e) => setForm((f) => ({ ...f, phoneNumber: e.target.value }))} className="h-9 text-sm"/>
</div>
</div>
<div className="flex gap-2 mt-3">
<Button size="sm" onClick={handleSave} disabled={isSaving}>
<Check className="h-3.5 w-3.5 mr-1"/>
{isSaving ? "Saving..." : "Save"}
</Button>
<Button size="sm" variant="outline" onClick={cancelAdd} disabled={isSaving}>
<X className="h-3.5 w-3.5 mr-1"/> Cancel
</Button>
</div>
</div>)}
{/* Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Insurance Company
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Phone Number
</th>
<th className="px-4 py-3 w-24"/>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (<tr>
<td colSpan={3} className="text-center py-6 text-sm text-gray-400">
Loading...
</td>
</tr>) : contacts.length === 0 ? (<tr>
<td colSpan={3} className="text-center py-10 text-sm text-gray-400">
No insurance contacts yet. Click "Add Contact" to add one.
</td>
</tr>) : (contacts.map((c) => editingId === c.id ? (
/* Inline edit row */
<tr key={c.id} className="bg-blue-50">
<td className="px-4 py-2">
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} className="h-8 text-sm" placeholder="Company name"/>
</td>
<td className="px-4 py-2">
<Input value={form.phoneNumber} onChange={(e) => setForm((f) => ({ ...f, phoneNumber: e.target.value }))} className="h-8 text-sm" placeholder="Phone number"/>
</td>
<td className="px-4 py-2 text-right">
<Button variant="ghost" size="sm" onClick={handleSave} disabled={isSaving} className="text-green-600 hover:text-green-700">
<Check className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" onClick={cancelEdit} disabled={isSaving}>
<X className="h-4 w-4"/>
</Button>
</td>
</tr>) : (
/* Display row */
<tr key={c.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{c.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">
{c.phoneNumber || <span className="text-gray-300"></span>}
</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
<Pencil className="h-4 w-4 text-gray-500"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(c)}>
<Trash2 className="h-4 w-4 text-red-500"/>
</Button>
</td>
</tr>)))}
</tbody>
</table>
</div>
<DeleteConfirmationDialog isOpen={!!deleteTarget} onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)} onCancel={() => setDeleteTarget(null)} entityName={deleteTarget?.name}/>
</div>);
}

View File

@@ -0,0 +1,155 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "../ui/button";
import { Edit, Delete, Plus } from "lucide-react";
import { CredentialForm } from "./InsuranceCredForm";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import { useAuth } from "@/hooks/use-auth";
export function CredentialTable() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [currentPage, setCurrentPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editingCred, setEditingCred] = useState(null);
const credentialsPerPage = 5;
const { data: credentials = [], isLoading, error } = useQuery({
queryKey: ["/api/insuranceCreds/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/insuranceCreds/");
if (!res.ok)
throw new Error("Failed to fetch credentials");
return res.json();
},
});
const deleteMutation = useMutation({
mutationFn: async (cred) => {
const res = await apiRequest("DELETE", `/api/insuranceCreds/${cred.id}`);
if (!res.ok)
throw new Error("Failed to delete credential");
return true;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/insuranceCreds/"] });
},
});
// New state for delete dialog
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [credentialToDelete, setCredentialToDelete] = useState(null);
const handleDeleteClick = (cred) => {
setCredentialToDelete(cred);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = () => {
if (credentialToDelete) {
deleteMutation.mutate(credentialToDelete, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
setCredentialToDelete(null);
},
});
}
};
const handleCancelDelete = () => {
setIsDeleteDialogOpen(false);
setCredentialToDelete(null);
};
const indexOfLast = currentPage * credentialsPerPage;
const indexOfFirst = indexOfLast - credentialsPerPage;
const currentCredentials = credentials.slice(indexOfFirst, indexOfLast);
const totalPages = Math.ceil(credentials.length / credentialsPerPage);
return (<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Insurance Credentials</h2>
<Button onClick={() => {
setEditingCred(null);
setModalOpen(true);
}}>
<Plus className="mr-2 h-4 w-4"/> Add Credentials
</Button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Site Key
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Password
</th>
<th className="px-4 py-2"/>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (<tr>
<td colSpan={4} className="text-center py-4">
Loading credentials...
</td>
</tr>) : error ? (<tr>
<td colSpan={4} className="text-center py-4 text-red-600">
Error loading credentials
</td>
</tr>) : currentCredentials.length === 0 ? (<tr>
<td colSpan={4} className="text-center py-4">
No credentials found.
</td>
</tr>) : (currentCredentials.map((cred) => (<tr key={cred.id}>
<td className="px-4 py-2">{cred.siteKey}</td>
<td className="px-4 py-2">{cred.username}</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2 text-right">
<Button variant="ghost" size="sm" onClick={() => {
setEditingCred(cred);
setModalOpen(true);
}}>
<Edit className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteClick(cred)}>
<Delete className="h-4 w-4 text-red-600"/>
</Button>
</td>
</tr>)))}
</tbody>
</table>
</div>
{/* Pagination */}
{credentials.length > credentialsPerPage && (<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200">
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{indexOfFirst + 1}</span> to{" "}
<span className="font-medium">{Math.min(indexOfLast, credentials.length)}</span> of{" "}
<span className="font-medium">{credentials.length}</span> results
</p>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<a href="#" onClick={(e) => { e.preventDefault(); if (currentPage > 1)
setCurrentPage(currentPage - 1); }} className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 ${currentPage === 1 ? "pointer-events-none opacity-50" : ""}`}>
Previous
</a>
{Array.from({ length: totalPages }).map((_, i) => (<a key={i} href="#" onClick={(e) => { e.preventDefault(); setCurrentPage(i + 1); }} className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${currentPage === i + 1
? "z-10 bg-blue-50 border-blue-500 text-blue-600"
: "border-gray-300 text-gray-500 hover:bg-gray-50"}`}>
{i + 1}
</a>))}
<a href="#" onClick={(e) => { e.preventDefault(); if (currentPage < totalPages)
setCurrentPage(currentPage + 1); }} className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 ${currentPage === totalPages ? "pointer-events-none opacity-50" : ""}`}>
Next
</a>
</nav>
</div>
</div>)}
{/* Modal for Add/Edit */}
{modalOpen && currentUser && (<CredentialForm userId={currentUser.id} defaultValues={editingCred || undefined} onClose={() => setModalOpen(false)}/>)}
<DeleteConfirmationDialog isOpen={isDeleteDialogOpen} onConfirm={handleConfirmDelete} onCancel={handleCancelDelete} entityName={credentialToDelete?.siteKey}/>
</div>);
}

View File

@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
export function NpiProviderForm({ onClose, defaultValues }) {
const [npiNumber, setNpiNumber] = useState(defaultValues?.npiNumber || "");
const [providerName, setProviderName] = useState(defaultValues?.providerName || "");
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async () => {
const payload = {
npiNumber: npiNumber.trim(),
providerName: providerName.trim(),
};
const url = defaultValues?.id
? `/api/npiProviders/${defaultValues.id}`
: "/api/npiProviders/";
const method = defaultValues?.id ? "PUT" : "POST";
const res = await apiRequest(method, url, payload);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save NPI provider");
}
return res.json();
},
onSuccess: () => {
toast({
title: `NPI provider ${defaultValues?.id ? "updated" : "created"}.`,
});
queryClient.invalidateQueries({
queryKey: ["/api/npiProviders/"],
});
onClose();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
useEffect(() => {
setNpiNumber(defaultValues?.npiNumber || "");
setProviderName(defaultValues?.providerName || "");
}, [defaultValues]);
const handleSubmit = (e) => {
e.preventDefault();
if (!npiNumber || !providerName) {
toast({
title: "Error",
description: "All fields are required.",
variant: "destructive",
});
return;
}
mutation.mutate();
};
return (<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-lg">
<h2 className="text-lg font-bold mb-4">
{defaultValues?.id
? "Edit NPI Provider"
: "Create NPI Provider"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium">
NPI Number
</label>
<input type="text" value={npiNumber} onChange={(e) => setNpiNumber(e.target.value)} className="mt-1 p-2 border rounded w-full" placeholder="e.g., 1489890992"/>
</div>
<div>
<label className="block text-sm font-medium">
Provider Name
</label>
<input type="text" value={providerName} onChange={(e) => setProviderName(e.target.value)} className="mt-1 p-2 border rounded w-full" placeholder="e.g., Kai Gao"/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="text-gray-600 hover:underline">
Cancel
</button>
<button type="submit" disabled={mutation.isPending} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
{mutation.isPending
? defaultValues?.id
? "Updating..."
: "Creating..."
: defaultValues?.id
? "Update"
: "Create"}
</button>
</div>
</form>
</div>
</div>);
}

View File

@@ -0,0 +1,162 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "../ui/button";
import { Edit, Delete, Plus, ChevronUp, ChevronDown } from "lucide-react";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import { NpiProviderForm } from "./npiProviderForm";
export function NpiProviderTable() {
const queryClient = useQueryClient();
const [currentPage, setCurrentPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [providerToDelete, setProviderToDelete] = useState(null);
const providersPerPage = 5;
const { data: providers = [], isLoading, error } = useQuery({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
if (!res.ok)
throw new Error("Failed to fetch NPI providers");
return res.json();
},
});
const reorderMutation = useMutation({
mutationFn: async (orderedIds) => {
const res = await apiRequest("POST", "/api/npiProviders/reorder", { orderedIds });
if (!res.ok)
throw new Error("Failed to reorder NPI providers");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/npiProviders/"] });
},
});
const deleteMutation = useMutation({
mutationFn: async (provider) => {
const res = await apiRequest("DELETE", `/api/npiProviders/${provider.id}`);
if (!res.ok)
throw new Error("Failed to delete NPI provider");
return true;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/npiProviders/"] });
},
});
const handleMove = (index, direction) => {
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= providers.length)
return;
const newOrder = [...providers];
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
reorderMutation.mutate(newOrder.map((p) => p.id));
};
const handleDeleteClick = (provider) => {
setProviderToDelete(provider);
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = () => {
if (!providerToDelete)
return;
deleteMutation.mutate(providerToDelete, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
setProviderToDelete(null);
},
});
};
const indexOfLast = currentPage * providersPerPage;
const indexOfFirst = indexOfLast - providersPerPage;
const currentProviders = providers.slice(indexOfFirst, indexOfLast);
const totalPages = Math.ceil(providers.length / providersPerPage);
return (<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">NPI Providers</h2>
<Button onClick={() => {
setEditingProvider(null);
setModalOpen(true);
}}>
<Plus className="mr-2 h-4 w-4"/> Add NPI Providers
</Button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase w-28">
Provider #
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
NPI Number
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Provider Name
</th>
<th className="px-4 py-2 w-32"/>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (<tr>
<td colSpan={4} className="text-center py-4">
Loading NPI providers...
</td>
</tr>) : error ? (<tr>
<td colSpan={4} className="text-center py-4 text-red-600">
Error loading NPI providers
</td>
</tr>) : currentProviders.length === 0 ? (<tr>
<td colSpan={4} className="text-center py-4">
No NPI providers found.
</td>
</tr>) : (currentProviders.map((provider, pageIndex) => {
const globalIndex = indexOfFirst + pageIndex;
const isDefault = globalIndex === 0;
return (<tr key={provider.id} className={isDefault ? "bg-blue-50" : ""}>
<td className="px-4 py-2">
<span className={`inline-flex items-center gap-1 text-sm font-medium ${isDefault ? "text-blue-700" : "text-gray-600"}`}>
Provider #{globalIndex + 1}
{isDefault && (<span className="ml-1 text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
default
</span>)}
</span>
</td>
<td className="px-4 py-2 text-sm">{provider.npiNumber}</td>
<td className="px-4 py-2 text-sm">{provider.providerName}</td>
<td className="px-4 py-2 text-right">
<Button variant="ghost" size="sm" disabled={globalIndex === 0 || reorderMutation.isPending} onClick={() => handleMove(globalIndex, "up")} title="Move up">
<ChevronUp className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" disabled={globalIndex === providers.length - 1 || reorderMutation.isPending} onClick={() => handleMove(globalIndex, "down")} title="Move down">
<ChevronDown className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => {
setEditingProvider(provider);
setModalOpen(true);
}}>
<Edit className="h-4 w-4"/>
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteClick(provider)}>
<Delete className="h-4 w-4 text-red-600"/>
</Button>
</td>
</tr>);
}))}
</tbody>
</table>
</div>
{providers.length > providersPerPage && (<div className="px-4 py-3 border-t flex justify-between">
<Button variant="ghost" disabled={currentPage === 1} onClick={() => setCurrentPage((p) => p - 1)}>
Previous
</Button>
<Button variant="ghost" disabled={currentPage === totalPages} onClick={() => setCurrentPage((p) => p + 1)}>
Next
</Button>
</div>)}
{modalOpen && (<NpiProviderForm defaultValues={editingProvider || undefined} onClose={() => setModalOpen(false)}/>)}
<DeleteConfirmationDialog isOpen={isDeleteDialogOpen} onConfirm={handleConfirmDelete} onCancel={() => setIsDeleteDialogOpen(false)} entityName={providerToDelete?.providerName}/>
</div>);
}

View File

@@ -0,0 +1,136 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
export function OfficeContactCard() {
const { toast } = useToast();
const [officeName, setOfficeName] = useState("");
const [receptionistName, setReceptionistName] = useState("");
const [dentistName, setDentistName] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [email, setEmail] = useState("");
const [fax, setFax] = useState("");
const [streetAddress, setStreetAddress] = useState("");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [zipCode, setZipCode] = useState("");
const { data: contact, isLoading } = useQuery({
queryKey: ["/api/office-contact"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-contact");
if (!res.ok)
return null;
return res.json();
},
});
useEffect(() => {
if (contact) {
setOfficeName(contact.officeName ?? "");
setReceptionistName(contact.receptionistName ?? "");
setDentistName(contact.dentistName ?? "");
setPhoneNumber(contact.phoneNumber ?? "");
setEmail(contact.email ?? "");
setFax(contact.fax ?? "");
setStreetAddress(contact.streetAddress ?? "");
setCity(contact.city ?? "");
setState(contact.state ?? "");
setZipCode(contact.zipCode ?? "");
}
}, [contact]);
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/office-contact", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save office contact");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/office-contact"] });
toast({ title: "Office Contact Saved", description: "Office contact information has been saved." });
},
onError: (err) => {
toast({ title: "Error", description: err?.message || "Failed to save office contact", variant: "destructive" });
},
});
const handleSubmit = (e) => {
e.preventDefault();
saveMutation.mutate({ officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode });
};
return (<Card>
<CardContent className="space-y-4 py-6">
<div>
<h3 className="text-lg font-semibold">Office Contact</h3>
<p className="text-sm text-gray-500 mt-1">
Contact information for your dental office staff and communications.
</p>
</div>
{isLoading ? (<p className="text-sm text-gray-400">Loading...</p>) : (<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium">Dental Office Name</label>
<input type="text" value={officeName} onChange={(e) => setOfficeName(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. Summit Dental Care"/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium">Receptionist Name</label>
<input type="text" value={receptionistName} onChange={(e) => setReceptionistName(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. Jane Smith"/>
</div>
<div>
<label className="block text-sm font-medium">Dentist Name</label>
<input type="text" value={dentistName} onChange={(e) => setDentistName(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. Dr. John Doe"/>
</div>
<div>
<label className="block text-sm font-medium">Office Phone Number</label>
<input type="tel" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. (508) 555-0100"/>
</div>
<div>
<label className="block text-sm font-medium">Office Email Address</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. office@dentalclinic.com"/>
</div>
<div>
<label className="block text-sm font-medium">Office Fax</label>
<input type="tel" value={fax} onChange={(e) => setFax(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. (508) 555-0199"/>
</div>
</div>
<div className="pt-2">
<h4 className="text-sm font-semibold text-gray-700 mb-3">Office Address</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium">Street Address</label>
<input type="text" value={streetAddress} onChange={(e) => setStreetAddress(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. 123 Main Street"/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium">City</label>
<input type="text" value={city} onChange={(e) => setCity(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. Framingham"/>
</div>
<div>
<label className="block text-sm font-medium">State</label>
<input type="text" value={state} onChange={(e) => setState(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. MA"/>
</div>
<div>
<label className="block text-sm font-medium">ZIP Code</label>
<input type="text" value={zipCode} onChange={(e) => setZipCode(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm" placeholder="e.g. 01701"/>
</div>
</div>
</div>
</div>
<div className="pt-1">
<button type="submit" className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm" disabled={saveMutation.isPending}>
{saveMutation.isPending ? "Saving..." : "Save Office Contact"}
</button>
</div>
</form>)}
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,231 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Calendar } from "@/components/ui/calendar";
const DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
const DAY_LABELS = {
monday: "Monday",
tuesday: "Tuesday",
wednesday: "Wednesday",
thursday: "Thursday",
friday: "Friday",
saturday: "Saturday",
sunday: "Sunday",
};
const DEFAULT_DAY_HOURS = {
enabled: true,
amStart: "09:00",
amEnd: "12:00",
pmStart: "13:00",
pmEnd: "17:00",
};
const WEEKEND_DEFAULT = {
enabled: false,
amStart: "09:00",
amEnd: "12:00",
pmStart: "13:00",
pmEnd: "17:00",
};
function buildDefaultWeek() {
return {
monday: { ...DEFAULT_DAY_HOURS },
tuesday: { ...DEFAULT_DAY_HOURS },
wednesday: { ...DEFAULT_DAY_HOURS },
thursday: { ...DEFAULT_DAY_HOURS },
friday: { ...DEFAULT_DAY_HOURS },
saturday: { ...WEEKEND_DEFAULT },
sunday: { ...WEEKEND_DEFAULT },
};
}
const DEFAULT_OFFICE_HOURS = {
doctors: buildDefaultWeek(),
hygienists: buildDefaultWeek(),
};
function TimeSelect({ value, onChange, disabled, }) {
const options = [];
for (let h = 6; h <= 20; h++) {
options.push(`${String(h).padStart(2, "0")}:00`);
options.push(`${String(h).padStart(2, "0")}:30`);
}
const toDisplay = (t) => {
const parts = t.split(":").map(Number);
const hh = parts[0] ?? 0;
const mm = parts[1] ?? 0;
const period = hh >= 12 ? "PM" : "AM";
const h12 = hh > 12 ? hh - 12 : hh === 0 ? 12 : hh;
return `${h12}:${String(mm).padStart(2, "0")} ${period}`;
};
return (<select value={value} onChange={(e) => onChange(e.target.value)} disabled={disabled} className="border rounded px-1 py-0.5 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
{options.map((o) => (<option key={o} value={o}>
{toDisplay(o)}
</option>))}
</select>);
}
function DayRow({ day, hours, onChange, }) {
const set = (field, value) => onChange({ ...hours, [field]: value });
return (<div className={`grid grid-cols-[120px_1fr] gap-2 items-start py-2 border-b last:border-b-0 ${!hours.enabled ? "opacity-60" : ""}`}>
<label className="flex items-center gap-2 cursor-pointer pt-1">
<input type="checkbox" checked={hours.enabled} onChange={(e) => set("enabled", e.target.checked)} className="w-4 h-4 accent-teal-600"/>
<span className="text-sm font-medium">{DAY_LABELS[day]}</span>
</label>
<div className="flex flex-wrap gap-x-6 gap-y-1">
<div className="flex items-center gap-1.5 text-sm">
<span className="text-gray-500 w-6">AM</span>
<TimeSelect value={hours.amStart} onChange={(v) => set("amStart", v)} disabled={!hours.enabled}/>
<span className="text-gray-400"></span>
<TimeSelect value={hours.amEnd} onChange={(v) => set("amEnd", v)} disabled={!hours.enabled}/>
</div>
<div className="flex items-center gap-1.5 text-sm">
<span className="text-gray-500 w-6">PM</span>
<TimeSelect value={hours.pmStart} onChange={(v) => set("pmStart", v)} disabled={!hours.enabled}/>
<span className="text-gray-400"></span>
<TimeSelect value={hours.pmEnd} onChange={(v) => set("pmEnd", v)} disabled={!hours.enabled}/>
</div>
</div>
</div>);
}
function SectionHours({ title, subtitle, week, onChange, }) {
return (<div className="border rounded-lg p-4">
<div className="mb-3">
<h4 className="font-semibold text-gray-800">{title}</h4>
<p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>
</div>
{DAYS.map((day) => (<DayRow key={day} day={day} hours={week[day]} onChange={(updated) => onChange({ ...week, [day]: updated })}/>))}
</div>);
}
function toYMD(date) {
return date.toLocaleDateString("en-CA"); // "YYYY-MM-DD" in local time
}
function formatDisplayDate(ymd) {
// "2026-05-10" → "May 10, 2026"
const [y, m, d] = ymd.split("-").map(Number);
return new Date(y, m - 1, d).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
export function OfficeHoursCard() {
const { toast } = useToast();
const [formData, setFormData] = useState(DEFAULT_OFFICE_HOURS);
// Override-dates UI state (not persisted until Save is clicked)
const [overrideToggle, setOverrideToggle] = useState(false);
const [pickedDate, setPickedDate] = useState(undefined);
const { data: savedHours, isLoading } = useQuery({
queryKey: ["/api/office-hours"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-hours");
if (!res.ok)
return null;
return res.json();
},
});
useEffect(() => {
if (savedHours) {
setFormData({
doctors: { ...buildDefaultWeek(), ...savedHours.doctors },
hygienists: { ...buildDefaultWeek(), ...savedHours.hygienists },
overrideDates: savedHours.overrideDates ?? [],
});
}
}, [savedHours]);
const overrideDates = formData.overrideDates ?? [];
const addOverrideDate = () => {
if (!pickedDate)
return;
const ymd = toYMD(pickedDate);
if (overrideDates.includes(ymd))
return; // already added
setFormData((prev) => ({ ...prev, overrideDates: [...overrideDates, ymd].sort() }));
setPickedDate(undefined);
};
const removeOverrideDate = (ymd) => {
setFormData((prev) => ({
...prev,
overrideDates: overrideDates.filter((d) => d !== ymd),
}));
};
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/office-hours", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save office hours");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/office-hours"] });
toast({ title: "Office Hours Saved" });
},
onError: (err) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
if (isLoading)
return <p className="text-sm text-gray-400 py-4">Loading...</p>;
return (<Card>
<CardContent className="py-6 space-y-6">
<div>
<h3 className="text-lg font-semibold">Office Hours</h3>
<p className="text-sm text-gray-500 mt-1">
Define which time slots are available for scheduling. Appointments outside these hours
require a manual override by a dental assistant.
</p>
</div>
<SectionHours title="Doctors' Hours" subtitle="Applies to schedule columns A, B, C" week={formData.doctors} onChange={(updated) => setFormData((prev) => ({ ...prev, doctors: updated }))}/>
<SectionHours title="Hygienists' Hours" subtitle="Applies to schedule columns D, E, F" week={formData.hygienists} onChange={(updated) => setFormData((prev) => ({ ...prev, hygienists: updated }))}/>
{/* Override Office Hours section */}
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-gray-800">Override Office Hours</h4>
<p className="text-xs text-gray-500 mt-0.5">
On selected dates, all time slots are open with no restrictions.
</p>
</div>
{/* Toggle */}
<button type="button" onClick={() => setOverrideToggle((v) => !v)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${overrideToggle ? "bg-teal-600" : "bg-gray-300"}`} aria-pressed={overrideToggle}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${overrideToggle ? "translate-x-6" : "translate-x-1"}`}/>
</button>
</div>
{/* Calendar + add button — shown when toggle is on */}
{overrideToggle && (<div className="space-y-3">
<p className="text-sm text-gray-600">Select a date to add to the override list:</p>
<div className="border rounded-md inline-block">
<Calendar mode="single" selected={pickedDate} onSelect={(d) => setPickedDate(d ?? undefined)} className="p-2"/>
</div>
<div>
<button type="button" onClick={addOverrideDate} disabled={!pickedDate} className="px-3 py-1.5 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-40">
Add Date
</button>
</div>
</div>)}
{/* List of saved override dates */}
{overrideDates.length > 0 && (<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Override dates</p>
<ul className="space-y-1">
{overrideDates.map((ymd) => (<li key={ymd} className="flex items-center justify-between text-sm bg-teal-50 border border-teal-100 rounded px-3 py-1.5">
<span className="font-medium text-teal-800">{formatDisplayDate(ymd)}</span>
<button type="button" onClick={() => removeOverrideDate(ymd)} className="text-teal-400 hover:text-red-500 text-xs ml-4" title="Remove">
</button>
</li>))}
</ul>
</div>)}
</div>
<div className="pt-2">
<button onClick={() => saveMutation.mutate(formData)} disabled={saveMutation.isPending} className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm disabled:opacity-50">
{saveMutation.isPending ? "Saving..." : "Save Office Hours"}
</button>
</div>
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,550 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { Trash2, Plus, X, Pencil } from "lucide-react";
// ── Time grid helpers ─────────────────────────────────────────────────────────
function buildTimeSlots() {
const slots = [];
for (let h = 8; h <= 21; h++) {
for (let m = 0; m < 60; m += 15) {
if (h === 21 && m > 0)
continue;
const pad = (n) => n.toString().padStart(2, "0");
const time = `${pad(h)}:${pad(m)}`;
const h12 = h > 12 ? h - 12 : h === 0 ? 12 : h;
const period = h >= 12 ? "PM" : "AM";
const display = `${h12}:${pad(m)} ${period}`;
slots.push({ time, display });
}
}
return slots;
}
const TIME_SLOTS = buildTimeSlots();
function timeToIdx(time) {
const [h, m] = time.split(":").map(Number);
return (h - 8) * 4 + Math.floor(m / 15);
}
function idxToTime(idx) {
const totalMin = idx * 15 + 8 * 60;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
function uid() {
return Math.random().toString(36).slice(2, 10);
}
const DURATION_OPTIONS = [15, 30, 45, 60, 75, 90, 120];
const COL_COLORS = {
A: "bg-sky-500",
B: "bg-teal-500",
C: "bg-indigo-500",
};
const COL_LIGHT = {
A: "bg-sky-100 border-sky-300 text-sky-800",
B: "bg-teal-100 border-teal-300 text-teal-800",
C: "bg-indigo-100 border-indigo-300 text-indigo-800",
};
// ── Main component ────────────────────────────────────────────────────────────
export function ProcedureTimeslotCard() {
const { toast } = useToast();
// ── Remote data ─────────────────────────────────────────────────
const { data: remote, isLoading } = useQuery({
queryKey: ["/api/procedure-timeslot"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/procedure-timeslot");
if (!res.ok)
throw new Error("Failed to load");
return res.json();
},
});
const saveMutation = useMutation({
mutationFn: async (payload) => {
const res = await apiRequest("PUT", "/api/procedure-timeslot", { data: payload });
if (!res.ok)
throw new Error("Failed to save");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/procedure-timeslot"] });
toast({ title: "Saved", description: "Settings saved successfully." });
},
onError: () => toast({ title: "Error", description: "Failed to save.", variant: "destructive" }),
});
// ── Section 1 state: Procedure durations ─────────────────────────
const [procedures, setProcedures] = useState([]);
const [newCode, setNewCode] = useState("");
const [newDesc, setNewDesc] = useState("");
const [newDuration, setNewDuration] = useState(30);
// ── Section 2 state: Doctor time slots ───────────────────────────
const [doctorSlots, setDoctorSlots] = useState([]);
// Drag selection state
const [dragCol, setDragCol] = useState(null);
const [dragStartIdx, setDragStartIdx] = useState(null);
const [dragEndIdx, setDragEndIdx] = useState(null);
const mouseDownRef = useRef(false);
// Pending slot confirmation
const [pendingSlot, setPendingSlot] = useState(null);
const [pendingLabel, setPendingLabel] = useState("");
// Edit existing slot
const [editingSlot, setEditingSlot] = useState(null);
const [editLabel, setEditLabel] = useState("");
const [editStart, setEditStart] = useState("");
const [editEnd, setEditEnd] = useState("");
const [editCol, setEditCol] = useState("A");
// ── Section 3 state: Hygienist slots ─────────────────────────────
const [hygienistSlots, setHygienistSlots] = useState([]);
const [newHygDesc, setNewHygDesc] = useState("");
const [newHygDuration, setNewHygDuration] = useState(30);
// Populate from remote when loaded
useEffect(() => {
if (!remote?.data)
return;
const d = remote.data;
setProcedures(d.procedures ?? []);
setDoctorSlots(d.doctorSlots ?? []);
setHygienistSlots(d.hygienistSlots ?? []);
}, [remote]);
// Global mouseup handler to end drag regardless of cursor position
useEffect(() => {
const onMouseUp = () => {
if (!mouseDownRef.current)
return;
mouseDownRef.current = false;
if (dragCol !== null && dragStartIdx !== null && dragEndIdx !== null) {
const s = Math.min(dragStartIdx, dragEndIdx);
const e = Math.max(dragStartIdx, dragEndIdx);
if (e > s) {
setPendingSlot({ col: dragCol, startIdx: s, endIdx: e });
setPendingLabel("");
}
}
setDragCol(null);
setDragStartIdx(null);
setDragEndIdx(null);
};
window.addEventListener("mouseup", onMouseUp);
return () => window.removeEventListener("mouseup", onMouseUp);
}, [dragCol, dragStartIdx, dragEndIdx]);
// ── Drag handlers ────────────────────────────────────────────────
const handleCellMouseDown = useCallback((col, idx) => {
mouseDownRef.current = true;
setDragCol(col);
setDragStartIdx(idx);
setDragEndIdx(idx);
}, []);
const handleCellMouseEnter = useCallback((col, idx) => {
if (!mouseDownRef.current || dragCol !== col)
return;
setDragEndIdx(idx);
}, [dragCol]);
const isDragHighlighted = (col, idx) => {
if (dragCol !== col || dragStartIdx === null || dragEndIdx === null)
return false;
const s = Math.min(dragStartIdx, dragEndIdx);
const e = Math.max(dragStartIdx, dragEndIdx);
return idx >= s && idx <= e;
};
// ── Doctor slot helpers ──────────────────────────────────────────
const getSlotOccupancy = (col, rowIdx) => {
for (const slot of doctorSlots) {
if (slot.column !== col)
continue;
const startIdx = timeToIdx(slot.startTime);
const endIdx = timeToIdx(slot.endTime);
if (rowIdx === startIdx)
return { slot, isStart: true, rowSpan: endIdx - startIdx };
if (rowIdx > startIdx && rowIdx < endIdx)
return { slot, isStart: false, rowSpan: 0 };
}
return null;
};
const confirmPendingSlot = () => {
if (!pendingSlot)
return;
// Check for overlap with existing slots in same column
const newStart = idxToTime(pendingSlot.startIdx);
const newEnd = idxToTime(pendingSlot.endIdx);
const overlap = doctorSlots.some((s) => {
if (s.column !== pendingSlot.col)
return false;
return !(newEnd <= s.startTime || newStart >= s.endTime);
});
if (overlap) {
toast({ title: "Overlap", description: "This time range overlaps an existing slot.", variant: "destructive" });
setPendingSlot(null);
return;
}
setDoctorSlots((prev) => [
...prev,
{ id: uid(), column: pendingSlot.col, startTime: newStart, endTime: newEnd, label: pendingLabel.trim() },
]);
setPendingSlot(null);
setPendingLabel("");
};
const deleteDocSlot = (id) => setDoctorSlots((prev) => prev.filter((s) => s.id !== id));
const openEditSlot = (slot) => {
setEditingSlot(slot);
setEditLabel(slot.label);
setEditStart(slot.startTime);
setEditEnd(slot.endTime);
setEditCol(slot.column);
setPendingSlot(null);
};
const saveEditSlot = () => {
if (!editingSlot)
return;
if (editStart >= editEnd) {
toast({ title: "Invalid time range", description: "End time must be after start time.", variant: "destructive" });
return;
}
const overlap = doctorSlots.some((s) => {
if (s.id === editingSlot.id || s.column !== editCol)
return false;
return !(editEnd <= s.startTime || editStart >= s.endTime);
});
if (overlap) {
toast({ title: "Overlap", description: "This time range overlaps an existing slot.", variant: "destructive" });
return;
}
setDoctorSlots((prev) => prev.map((s) => s.id === editingSlot.id
? { ...s, column: editCol, startTime: editStart, endTime: editEnd, label: editLabel.trim() }
: s));
setEditingSlot(null);
};
const confirmDeleteEditSlot = () => {
if (!editingSlot)
return;
deleteDocSlot(editingSlot.id);
setEditingSlot(null);
};
// ── Save helpers ─────────────────────────────────────────────────
const buildPayload = () => ({ procedures, doctorSlots, hygienistSlots });
const handleSaveProcedures = () => saveMutation.mutate({ ...buildPayload(), procedures });
const handleSaveDoctorSlots = () => saveMutation.mutate({ ...buildPayload(), doctorSlots });
const handleSaveHygienistSlots = () => saveMutation.mutate({ ...buildPayload(), hygienistSlots });
// ── Section 1 actions ────────────────────────────────────────────
const addProcedure = () => {
if (!newCode.trim())
return;
setProcedures((prev) => [...prev, { id: uid(), code: newCode.trim().toUpperCase(), description: newDesc.trim(), durationMin: newDuration }]);
setNewCode("");
setNewDesc("");
setNewDuration(30);
};
const deleteProcedure = (id) => setProcedures((prev) => prev.filter((p) => p.id !== id));
const updateProcedure = (id, field, value) => {
setProcedures((prev) => prev.map((p) => (p.id === id ? { ...p, [field]: value } : p)));
};
// ── Section 3 actions ────────────────────────────────────────────
const addHygSlot = () => {
if (!newHygDesc.trim())
return;
setHygienistSlots((prev) => [...prev, { id: uid(), description: newHygDesc.trim(), durationMin: newHygDuration }]);
setNewHygDesc("");
setNewHygDuration(30);
};
const deleteHygSlot = (id) => setHygienistSlots((prev) => prev.filter((s) => s.id !== id));
const updateHygSlot = (id, field, value) => {
setHygienistSlots((prev) => prev.map((s) => (s.id === id ? { ...s, [field]: value } : s)));
};
// ── Render ───────────────────────────────────────────────────────
if (isLoading)
return <Card><CardContent className="py-10 text-center text-gray-400">Loading...</CardContent></Card>;
return (<div className="space-y-6">
{/* ── Section 1: Procedure Duration ── */}
<Card>
<CardContent className="py-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-800">Procedure Duration</h2>
<p className="text-sm text-gray-500 mt-0.5">Set the standard time needed for common dental procedures by CDT code.</p>
</div>
{/* Existing procedure rows */}
{procedures.length > 0 && (<div className="border rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-3 py-2 font-medium text-gray-600 w-32">CDT Code</th>
<th className="text-left px-3 py-2 font-medium text-gray-600">Description</th>
<th className="text-left px-3 py-2 font-medium text-gray-600 w-36">Duration</th>
<th className="w-10"/>
</tr>
</thead>
<tbody className="divide-y">
{procedures.map((proc) => (<tr key={proc.id} className="hover:bg-gray-50">
<td className="px-3 py-2">
<input type="text" value={proc.code} onChange={(e) => updateProcedure(proc.id, "code", e.target.value.toUpperCase())} className="w-full border rounded px-2 py-1 text-sm font-mono uppercase" placeholder="D1110"/>
</td>
<td className="px-3 py-2">
<input type="text" value={proc.description} onChange={(e) => updateProcedure(proc.id, "description", e.target.value)} className="w-full border rounded px-2 py-1 text-sm" placeholder="Adult Prophy"/>
</td>
<td className="px-3 py-2">
<select value={proc.durationMin} onChange={(e) => updateProcedure(proc.id, "durationMin", Number(e.target.value))} className="border rounded px-2 py-1 text-sm w-full">
{DURATION_OPTIONS.map((d) => (<option key={d} value={d}>{d} min</option>))}
</select>
</td>
<td className="px-2 py-2 text-center">
<button onClick={() => deleteProcedure(proc.id)} className="text-red-400 hover:text-red-600">
<Trash2 size={15}/>
</button>
</td>
</tr>))}
</tbody>
</table>
</div>)}
{/* Add new procedure row */}
<div className="flex gap-2 items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">CDT Code</label>
<input type="text" value={newCode} onChange={(e) => setNewCode(e.target.value.toUpperCase())} onKeyDown={(e) => e.key === "Enter" && addProcedure()} className="border rounded px-2 py-1.5 text-sm font-mono uppercase w-28" placeholder="D1110"/>
</div>
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs font-medium text-gray-600">Description</label>
<input type="text" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addProcedure()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Adult Prophy"/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Duration</label>
<select value={newDuration} onChange={(e) => setNewDuration(Number(e.target.value))} className="border rounded px-2 py-1.5 text-sm">
{DURATION_OPTIONS.map((d) => (<option key={d} value={d}>{d} min</option>))}
</select>
</div>
<button onClick={addProcedure} className="flex items-center gap-1 bg-teal-600 text-white px-3 py-1.5 rounded hover:bg-teal-700 text-sm">
<Plus size={14}/> Add
</button>
</div>
<div className="flex justify-end pt-1">
<button onClick={handleSaveProcedures} disabled={saveMutation.isPending} className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm">
{saveMutation.isPending ? "Saving…" : "Save Procedures"}
</button>
</div>
</CardContent>
</Card>
{/* ── Section 2: Doctor Time Slot Settings ── */}
<Card>
<CardContent className="py-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-800">Doctor Time Slot Settings</h2>
<p className="text-sm text-gray-500 mt-0.5">
Template reference for scheduling drag to block time ranges in columns A, B, C. This does not affect the main schedule.
</p>
<div className="flex gap-4 mt-2">
{["A", "B", "C"].map((col) => (<span key={col} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${COL_LIGHT[col]}`}>
<span className={`w-2.5 h-2.5 rounded-full ${COL_COLORS[col]}`}/> Column {col}
</span>))}
<span className="text-xs text-gray-400 self-center">Drag cells to block a time range</span>
</div>
</div>
{/* Pending slot confirmation dialog */}
{pendingSlot && (<div className="border border-blue-200 bg-blue-50 rounded-lg p-4 flex flex-col gap-3">
<p className="text-sm font-medium text-blue-800">
New slot in column <strong>{pendingSlot.col}</strong>:{" "}
{TIME_SLOTS[pendingSlot.startIdx]?.display} {TIME_SLOTS[pendingSlot.endIdx]?.display}
</p>
<div className="flex gap-2 items-center">
<input type="text" value={pendingLabel} onChange={(e) => setPendingLabel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && confirmPendingSlot()} className="border rounded px-2 py-1.5 text-sm flex-1" placeholder="Label (e.g. Root Canal D3330) — optional" autoFocus/>
<button onClick={confirmPendingSlot} className="bg-teal-600 text-white px-3 py-1.5 rounded text-sm hover:bg-teal-700">
Confirm
</button>
<button onClick={() => setPendingSlot(null)} className="text-gray-500 hover:text-gray-700">
<X size={18}/>
</button>
</div>
</div>)}
{/* Edit existing slot dialog */}
{editingSlot && (<div className="border border-amber-200 bg-amber-50 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-amber-800 flex items-center gap-1.5">
<Pencil size={14}/> Edit Slot
</p>
<button onClick={() => setEditingSlot(null)} className="text-gray-400 hover:text-gray-600">
<X size={16}/>
</button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Column</label>
<select value={editCol} onChange={(e) => setEditCol(e.target.value)} className="border rounded px-2 py-1.5 text-sm">
{["A", "B", "C"].map((c) => (<option key={c} value={c}>{c}</option>))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Start Time</label>
<select value={editStart} onChange={(e) => setEditStart(e.target.value)} className="border rounded px-2 py-1.5 text-sm">
{TIME_SLOTS.slice(0, -1).map(({ time, display }) => (<option key={time} value={time}>{display}</option>))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">End Time</label>
<select value={editEnd} onChange={(e) => setEditEnd(e.target.value)} className="border rounded px-2 py-1.5 text-sm">
{TIME_SLOTS.filter(({ time }) => time > editStart).map(({ time, display }) => (<option key={time} value={time}>{display}</option>))}
</select>
</div>
<div className="flex flex-col gap-1 sm:col-span-1 col-span-2">
<label className="text-xs font-medium text-gray-600">Label</label>
<input type="text" value={editLabel} onChange={(e) => setEditLabel(e.target.value)} onKeyDown={(e) => e.key === "Enter" && saveEditSlot()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Root Canal D3330" autoFocus/>
</div>
</div>
<div className="flex gap-2 justify-between pt-1">
<button onClick={confirmDeleteEditSlot} className="flex items-center gap-1 text-red-600 border border-red-200 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded text-sm">
<Trash2 size={13}/> Delete Slot
</button>
<div className="flex gap-2">
<button onClick={() => setEditingSlot(null)} className="border px-3 py-1.5 rounded text-sm text-gray-600 hover:bg-gray-100">
Cancel
</button>
<button onClick={saveEditSlot} className="bg-teal-600 text-white px-4 py-1.5 rounded text-sm hover:bg-teal-700">
Save Changes
</button>
</div>
</div>
</div>)}
{/* Time grid */}
<div className="border rounded overflow-auto max-h-[520px]">
<table className="w-full text-xs border-collapse select-none" style={{ tableLayout: "fixed" }} onMouseLeave={() => {
// don't cancel drag on table leave — global mouseup handles it
}}>
<colgroup>
<col style={{ width: "72px" }}/>
<col />
<col />
<col />
</colgroup>
<thead className="sticky top-0 z-10 bg-white border-b">
<tr>
<th className="text-left px-2 py-2 font-medium text-gray-500 border-r">Time</th>
{["A", "B", "C"].map((col) => (<th key={col} className={`py-2 font-semibold text-center border-r last:border-r-0 ${COL_LIGHT[col]}`}>
{col}
</th>))}
</tr>
</thead>
<tbody>
{TIME_SLOTS.map(({ time, display }, rowIdx) => {
const isHour = time.endsWith(":00");
return (<tr key={time} className={isHour ? "border-t border-gray-300" : "border-t border-gray-100"}>
{/* Time label */}
<td className="px-2 py-0 border-r text-gray-400 whitespace-nowrap" style={{ height: "22px" }}>
{isHour ? display : ""}
</td>
{/* Columns A / B / C */}
{["A", "B", "C"].map((col) => {
const occ = getSlotOccupancy(col, rowIdx);
if (occ && !occ.isStart)
return null; // spanned — skip td
if (occ && occ.isStart) {
const isBeingEdited = editingSlot?.id === occ.slot.id;
return (<td key={col} rowSpan={occ.rowSpan} className={`border-r last:border-r-0 px-1 py-0.5 align-top cursor-pointer ${isBeingEdited
? "ring-2 ring-inset ring-amber-400 " + COL_LIGHT[col]
: COL_LIGHT[col]}`} onClick={(e) => { e.stopPropagation(); openEditSlot(occ.slot); }}>
<div className="flex items-start justify-between gap-1 h-full">
<div className="min-w-0">
<div className="font-medium leading-tight break-words text-[11px]">
{occ.slot.label || "Slot"}
</div>
<div className="text-[10px] opacity-70 mt-0.5 whitespace-nowrap">
{TIME_SLOTS[timeToIdx(occ.slot.startTime)]?.display} {TIME_SLOTS[timeToIdx(occ.slot.endTime)]?.display}
</div>
</div>
<div className="flex flex-col gap-0.5 flex-shrink-0">
<button onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); openEditSlot(occ.slot); }} className="text-gray-500 hover:text-amber-600" title="Edit">
<Pencil size={11}/>
</button>
<button onMouseDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); deleteDocSlot(occ.slot.id); if (editingSlot?.id === occ.slot.id)
setEditingSlot(null); }} className="text-red-400 hover:text-red-600" title="Delete">
<X size={11}/>
</button>
</div>
</div>
</td>);
}
// Empty / draggable cell
const highlighted = isDragHighlighted(col, rowIdx);
return (<td key={col} className={`border-r last:border-r-0 cursor-crosshair transition-colors ${highlighted ? "bg-blue-200" : "hover:bg-gray-50"}`} onMouseDown={() => handleCellMouseDown(col, rowIdx)} onMouseEnter={() => handleCellMouseEnter(col, rowIdx)}/>);
})}
</tr>);
})}
</tbody>
</table>
</div>
<div className="flex justify-end pt-1">
<button onClick={handleSaveDoctorSlots} disabled={saveMutation.isPending} className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm">
{saveMutation.isPending ? "Saving…" : "Save Doctor Slots"}
</button>
</div>
</CardContent>
</Card>
{/* ── Section 3: Hygienist Time Slot Settings ── */}
<Card>
<CardContent className="py-6 space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-800">Hygienist Time Slot Settings</h2>
<p className="text-sm text-gray-500 mt-0.5">
Define standard procedure types for hygienists with typical durations.
</p>
</div>
{hygienistSlots.length > 0 && (<div className="border rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-3 py-2 font-medium text-gray-600">Procedure Description</th>
<th className="text-left px-3 py-2 font-medium text-gray-600 w-36">Duration</th>
<th className="w-10"/>
</tr>
</thead>
<tbody className="divide-y">
{hygienistSlots.map((slot) => (<tr key={slot.id} className="hover:bg-gray-50">
<td className="px-3 py-2">
<input type="text" value={slot.description} onChange={(e) => updateHygSlot(slot.id, "description", e.target.value)} className="w-full border rounded px-2 py-1 text-sm" placeholder="e.g. Exam / Recalls / Teeth Cleaning"/>
</td>
<td className="px-3 py-2">
<select value={slot.durationMin} onChange={(e) => updateHygSlot(slot.id, "durationMin", Number(e.target.value))} className="border rounded px-2 py-1 text-sm w-full">
{DURATION_OPTIONS.map((d) => (<option key={d} value={d}>{d} min</option>))}
</select>
</td>
<td className="px-2 py-2 text-center">
<button onClick={() => deleteHygSlot(slot.id)} className="text-red-400 hover:text-red-600">
<Trash2 size={15}/>
</button>
</td>
</tr>))}
</tbody>
</table>
</div>)}
<div className="flex gap-2 items-end">
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs font-medium text-gray-600">Procedure Description</label>
<input type="text" value={newHygDesc} onChange={(e) => setNewHygDesc(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addHygSlot()} className="border rounded px-2 py-1.5 text-sm w-full" placeholder="e.g. Exam, Recalls / Teeth Cleaning"/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">Duration</label>
<select value={newHygDuration} onChange={(e) => setNewHygDuration(Number(e.target.value))} className="border rounded px-2 py-1.5 text-sm">
{DURATION_OPTIONS.map((d) => (<option key={d} value={d}>{d} min</option>))}
</select>
</div>
<button onClick={addHygSlot} className="flex items-center gap-1 bg-teal-600 text-white px-3 py-1.5 rounded hover:bg-teal-700 text-sm">
<Plus size={14}/> Add
</button>
</div>
<div className="flex justify-end pt-1">
<button onClick={handleSaveHygienistSlots} disabled={saveMutation.isPending} className="bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm">
{saveMutation.isPending ? "Saving…" : "Save Hygienist Slots"}
</button>
</div>
</CardContent>
</Card>
</div>);
}

View File

@@ -0,0 +1,219 @@
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";
const CATEGORY_LABELS = {
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 = {
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 = () => ({
id: nextId++,
name: "",
category: "other",
executablePath: "",
arguments: "",
enabled: true,
notes: "",
});
export function ProgramBridgeTable() {
const [bridges, setBridges] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState(newBridge());
const openAdd = () => {
setEditing(newBridge());
setDialogOpen(true);
};
const openEdit = (bridge) => {
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) => {
setBridges((prev) => prev.filter((b) => b.id !== id));
};
const toggleEnabled = (id) => {
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 }))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(CATEGORY_LABELS).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,123 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Eye, EyeOff, CheckCircle } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
export function TwilioSettingsCard() {
const { toast } = useToast();
const [accountSid, setAccountSid] = useState("");
const [authToken, setAuthToken] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [greetingMessage, setGreetingMessage] = useState("");
const [twimlAppSid, setTwimlAppSid] = useState("");
const [showAuthToken, setShowAuthToken] = useState(false);
const { data: settings, isLoading } = useQuery({
queryKey: ["/api/twilio/settings"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/twilio/settings");
if (!res.ok)
return null;
return res.json();
},
});
useEffect(() => {
if (settings) {
setAccountSid(settings.accountSid ?? "");
setAuthToken(settings.authToken ?? "");
setPhoneNumber(settings.phoneNumber ?? "");
setGreetingMessage(settings.greetingMessage ?? "");
setTwimlAppSid(settings.twimlAppSid ?? "");
}
}, [settings]);
const saveMutation = useMutation({
mutationFn: async (data) => {
const res = await apiRequest("PUT", "/api/twilio/settings", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save Twilio settings");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/twilio/settings"] });
toast({ title: "Twilio Settings Saved", description: "Your Twilio credentials have been saved." });
},
onError: (err) => {
toast({ title: "Error", description: err?.message || "Failed to save settings", variant: "destructive" });
},
});
const handleSubmit = (e) => {
e.preventDefault();
if (!accountSid.trim() || !authToken.trim() || !phoneNumber.trim())
return;
saveMutation.mutate({ accountSid: accountSid.trim(), authToken: authToken.trim(), phoneNumber: phoneNumber.trim(), greetingMessage: greetingMessage.trim() || null, twimlAppSid: twimlAppSid.trim() || null });
};
const isConfigured = !!(settings?.accountSid && settings?.authToken && settings?.phoneNumber);
return (<Card>
<CardContent className="space-y-4 py-6">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Twilio Settings</h3>
{isConfigured && (<span className="flex items-center gap-1 text-xs text-green-600 font-medium">
<CheckCircle className="h-3.5 w-3.5"/> Configured
</span>)}
</div>
<p className="text-sm text-gray-500">
Enter your Twilio credentials to enable SMS and calling features. Find these in your{" "}
<span className="font-medium">Twilio Console</span> Account Info.
</p>
{isLoading ? (<p className="text-sm text-gray-400">Loading...</p>) : (<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium">Account SID</label>
<input type="text" value={accountSid} onChange={(e) => setAccountSid(e.target.value)} className="mt-1 p-2 border rounded w-full font-mono text-sm" placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" required/>
</div>
<div>
<label className="block text-sm font-medium">Auth Token</label>
<div className="relative mt-1">
<input type={showAuthToken ? "text" : "password"} value={authToken} onChange={(e) => setAuthToken(e.target.value)} className="p-2 border rounded w-full pr-10 font-mono text-sm" placeholder="••••••••••••••••••••••••••••••••" required/>
<button type="button" onClick={() => setShowAuthToken((v) => !v)} className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700" tabIndex={-1}>
{showAuthToken ? <EyeOff size={16}/> : <Eye size={16}/>}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium">Twilio Phone Number</label>
<input type="text" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} className="mt-1 p-2 border rounded w-full font-mono text-sm" placeholder="+1xxxxxxxxxx" required/>
<p className="text-xs text-gray-500 mt-1">Must be in E.164 format, e.g. +16175551234</p>
</div>
<div>
<label className="block text-sm font-medium">Voicemail Greeting</label>
<textarea value={greetingMessage} onChange={(e) => setGreetingMessage(e.target.value)} className="mt-1 p-2 border rounded w-full text-sm resize-none" rows={3} placeholder="Thank you for calling. Please leave a message after the beep and we will get back to you shortly."/>
<p className="text-xs text-gray-500 mt-1">This message plays when a patient calls your Twilio number. Leave blank to use the default.</p>
</div>
<div>
<label className="block text-sm font-medium">TwiML App SID <span className="text-gray-400 font-normal">(for in-browser calling)</span></label>
<input type="text" value={twimlAppSid} onChange={(e) => setTwimlAppSid(e.target.value)} className="mt-1 p-2 border rounded w-full font-mono text-sm" placeholder="APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"/>
<p className="text-xs text-gray-500 mt-1">
In Twilio Console TwiML Apps create one set Voice URL to{" "}
<code className="bg-gray-100 px-1 rounded text-xs">https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/voice-browser</code>
{" "}then paste the App SID here. Required for the dial pad.
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<button type="submit" className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm" disabled={saveMutation.isPending}>
{saveMutation.isPending ? "Saving..." : "Save Twilio Settings"}
</button>
</div>
{isConfigured && (<div className="mt-2 p-3 bg-gray-50 rounded border text-xs text-gray-600 space-y-1">
<p className="font-medium text-gray-700">Twilio Webhook URLs</p>
<p>SMS: <code className="bg-white border px-1 rounded">https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/sms</code></p>
<p>Voice: <code className="bg-white border px-1 rounded">https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/voice</code></p>
<p className="text-gray-400">Set these in your Twilio Console Phone Numbers your number</p>
</div>)}
</form>)}
</CardContent>
</Card>);
}

View File

@@ -0,0 +1,75 @@
import React, { useState, useEffect } from "react";
export function StaffForm({ initialData, onSubmit, onCancel, isLoading, }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState("Staff");
const [phone, setPhone] = useState("");
const [hasTypedRole, setHasTypedRole] = useState(false);
// Set initial values once on mount
useEffect(() => {
if (initialData) {
if (initialData.name)
setName(initialData.name);
if (initialData.email)
setEmail(initialData.email);
if (initialData.role)
setRole(initialData.role);
if (initialData.phone)
setPhone(initialData.phone);
}
}, []); // run once only
const handleSubmit = (e) => {
e.preventDefault();
if (!name.trim()) {
alert("Name is required");
return;
}
onSubmit({
name: name.trim(),
email: email.trim() || undefined,
role: role.trim(),
phone: phone.trim() || undefined,
});
};
return (<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Name *
</label>
<input type="text" className="mt-1 block w-full border rounded p-2" value={name} onChange={(e) => setName(e.target.value)} required disabled={isLoading}/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input type="email" className="mt-1 block w-full border rounded p-2" value={email} onChange={(e) => setEmail(e.target.value)} disabled={isLoading}/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Role *
</label>
<input type="text" className="mt-1 block w-full border rounded p-2" value={role} onChange={(e) => {
setHasTypedRole(true);
setRole(e.target.value);
}} onFocus={() => {
if (!hasTypedRole && role === "Staff") {
setRole("");
}
}} required disabled={isLoading}/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Phone</label>
<input type="tel" className="mt-1 block w-full border rounded p-2" value={phone} onChange={(e) => setPhone(e.target.value)} disabled={isLoading}/>
</div>
<div className="flex justify-end space-x-2">
<button type="button" onClick={onCancel} className="px-4 py-2 border rounded" disabled={isLoading}>
Cancel
</button>
<button type="submit" className="px-4 py-2 bg-teal-600 text-white rounded disabled:opacity-50" disabled={isLoading}>
{isLoading ? "Saving..." : "Save"}
</button>
</div>
</form>);
}

View File

@@ -0,0 +1,161 @@
import React, { useState } from "react";
import { Button } from "../ui/button";
import { Delete, Edit } from "lucide-react";
export function StaffTable({ staff, onEdit, onView, onDelete, onAdd, }) {
const [currentPage, setCurrentPage] = useState(1);
const staffPerPage = 5;
const indexOfLastStaff = currentPage * staffPerPage;
const indexOfFirstStaff = indexOfLastStaff - staffPerPage;
const currentStaff = staff.slice(indexOfFirstStaff, indexOfLastStaff);
const totalPages = Math.ceil(staff.length / staffPerPage);
const getInitials = (name) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
};
const getAvatarColor = (id) => {
const colors = [
"bg-blue-500",
"bg-teal-500",
"bg-amber-500",
"bg-rose-500",
"bg-indigo-500",
"bg-green-500",
"bg-purple-500",
];
return colors[id % colors.length];
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
};
return (<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="flex justify-between items-center p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Staff Members</h2>
<button onClick={onAdd} className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700">
Add New Staffs
</button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Staff
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Phone
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Joined
</th>
<th className="relative px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentStaff.length === 0 ? (<tr>
<td colSpan={6} className="text-center py-8 text-gray-500">
No staff found. Add new staff to get started.
</td>
</tr>) : (currentStaff.map((staff) => {
const avatarId = staff.id ?? 0; // fallback if undefined
const formattedDate = staff.createdAt
? formatDate(typeof staff.createdAt === "string"
? staff.createdAt
: staff.createdAt.toISOString())
: "N/A";
return (<tr key={avatarId} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap flex items-center">
<div className={`h-10 w-10 rounded-full flex items-center justify-center text-white font-bold ${getAvatarColor(avatarId)}`}>
{getInitials(staff.name)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{staff.name}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{staff.email || "—"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm capitalize text-gray-900">
{staff.role}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{staff.phone || "—"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formattedDate}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<Button onClick={() => staff !== undefined && onDelete(staff)} className="text-red-600 hover:text-red-900" aria-label="Delete Staff" variant="ghost" size="icon">
<Delete />
</Button>
<Button onClick={() => staff.id !== undefined && onEdit(staff)} className="text-blue-600 hover:text-blue-900" aria-label="Edit Staff" variant="ghost" size="icon">
<Edit className="h-4 w-4"/>
</Button>
</td>
</tr>);
}))}
</tbody>
</table>
</div>
{staff.length > staffPerPage && (<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200">
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">{indexOfFirstStaff + 1}</span> to{" "}
<span className="font-medium">
{Math.min(indexOfLastStaff, staff.length)}
</span>{" "}
of <span className="font-medium">{staff.length}</span> results
</p>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<a href="#" onClick={(e) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}} className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 ${currentPage === 1 ? "pointer-events-none opacity-50" : ""}`}>
Previous
</a>
{Array.from({ length: totalPages }).map((_, i) => (<a key={i} href="#" onClick={(e) => {
e.preventDefault();
setCurrentPage(i + 1);
}} aria-current={currentPage === i + 1 ? "page" : undefined} className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${currentPage === i + 1
? "z-10 bg-teal-50 border-teal-500 text-teal-600"
: "border-gray-300 text-gray-500 hover:bg-gray-50"}`}>
{i + 1}
</a>))}
<a href="#" onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}} className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 ${currentPage === totalPages
? "pointer-events-none opacity-50"
: ""}`}>
Next
</a>
</nav>
</div>
</div>)}
</div>);
}

View File

@@ -0,0 +1,12 @@
import React from "react";
export default function LoadingScreen() {
return (<div className="flex items-center justify-center min-h-screen bg-white text-gray-800">
<div className="flex flex-col items-center space-y-4">
<svg className="animate-spin h-10 w-10 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<p className="text-lg font-semibold">Loading, please wait...</p>
</div>
</div>);
}

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props}/>));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger ref={ref} className={cn("flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", className)} {...props}>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200"/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (<AccordionPrimitive.Content ref={ref} className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" {...props}>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (<AlertDialogPrimitive.Overlay className={cn("fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className)} {...props} ref={ref}/>));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content ref={ref} className={cn("fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", className)} {...props}/>
</AlertDialogPortal>));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }) => (<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props}/>);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }) => (<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props}/>);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props}/>));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props}/>));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props}/>));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (<AlertDialogPrimitive.Cancel ref={ref} className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)} {...props}/>));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export { AlertDialog, AlertDialogPortal, AlertDialogOverlay, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", {
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
});
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props}/>));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props}/>));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props}/>));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,3 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef(({ className, ...props }, ref) => (<AvatarPrimitive.Root ref={ref} className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)} {...props}/>));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props}/>));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (<AvatarPrimitive.Fallback ref={ref} className={cn("flex h-full w-full items-center justify-center rounded-full", className)} {...props}/>));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", {
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success: "border-transparent bg-success text-success-foreground hover:bg-success/80", // ✅ Added success variant
warning: "border-transparent bg-warning text-warning-foreground hover:bg-warning/80", // ✅ Added warning variant
},
},
defaultVariants: {
variant: "default",
},
});
function Badge({ className, variant, ...props }) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props}/>);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props}/>);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (<ol ref={ref} className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", className)} {...props}/>));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props}/>));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (<Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props}/>);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (<span ref={ref} role="link" aria-disabled="true" aria-current="page" className={cn("font-normal text-foreground", className)} {...props}/>));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }) => (<li role="presentation" aria-hidden="true" className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} {...props}>
{children ?? <ChevronRight />}
</li>);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }) => (<span role="presentation" aria-hidden="true" className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
<MoreHorizontal className="h-4 w-4"/>
<span className="sr-only">More</span>
</span>);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis, };

View File

@@ -0,0 +1,35 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", {
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
warning: "bg-yellow-600 text-white hover:bg-yellow-700",
success: "bg-green-600 text-white hover:bg-green-700",
info: "bg-blue-600 text-white hover:bg-blue-700",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props}/>);
});
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,44 @@
import { useState, useEffect } from "react";
import { DayPicker } from "react-day-picker";
import "react-day-picker/style.css";
export function Calendar(props) {
const { mode, selected, onSelect, className, closeOnSelect, onClose, ...rest } = props;
const [internalSelected, setInternalSelected] = useState(selected);
useEffect(() => {
setInternalSelected(selected);
}, [selected]);
const handleSelect = (value) => {
setInternalSelected(value);
// forward original callback
onSelect?.(value);
// Decide whether to request closing
const shouldClose = typeof closeOnSelect !== "undefined"
? closeOnSelect
: mode === "single"
? true
: false;
if (!shouldClose)
return;
// For range: only close when both from and to exist
if (mode === "range") {
const range = value;
if (range?.from && range?.to) {
onClose?.();
}
return;
}
// For single or multiple (when allowed), close immediately
onClose?.();
};
return (<div className={`${className || ""} day-picker-small-scale`} style={{
transform: "scale(0.9)",
transformOrigin: "top left",
width: "fit-content",
height: "fit-content",
}}>
{mode === "single" && (<DayPicker mode="single" selected={internalSelected} onSelect={handleSelect} captionLayout="dropdown" // Enables month/year dropdown
{...rest}/>)}
{mode === "range" && (<DayPicker mode="range" selected={internalSelected} onSelect={handleSelect} {...rest}/>)}
{mode === "multiple" && (<DayPicker mode="multiple" selected={internalSelected} onSelect={handleSelect} {...rest}/>)}
</div>);
}

View File

@@ -0,0 +1,15 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef(({ className, ...props }, ref) => (<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props}/>));
Card.displayName = "Card";
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props}/>));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (<div ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props}/>));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props}/>));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef(({ className, ...props }, ref) => (<div ref={ref} className={cn("p-6 pt-0", className)} {...props}/>));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props}/>));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import useEmblaCarousel from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
const CarouselContext = React.createContext(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel({
...opts,
axis: orientation === "horizontal" ? "x" : "y",
}, plugins);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback((event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
}
else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
}, [scrollPrev, scrollNext]);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (<CarouselContext.Provider value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}>
<div ref={ref} onKeyDownCapture={handleKeyDown} className={cn("relative", className)} role="region" aria-roledescription="carousel" {...props}>
{children}
</div>
</CarouselContext.Provider>);
});
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (<div ref={carouselRef} className="overflow-hidden">
<div ref={ref} className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)} {...props}/>
</div>);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (<div ref={ref} role="group" aria-roledescription="slide" className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)} {...props}/>);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (<Button ref={ref} variant={variant} size={size} className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)} disabled={!canScrollPrev} onClick={scrollPrev} {...props}>
<ArrowLeft className="h-4 w-4"/>
<span className="sr-only">Previous slide</span>
</Button>);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (<Button ref={ref} variant={variant} size={size} className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)} disabled={!canScrollNext} onClick={scrollNext} {...props}>
<ArrowRight className="h-4 w-4"/>
<span className="sr-only">Next slide</span>
</Button>);
});
CarouselNext.displayName = "CarouselNext";
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, };

View File

@@ -0,0 +1,164 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" };
const ChartContext = React.createContext(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (<ChartContext.Provider value={{ config }}>
<div data-chart={chartId} ref={ref} className={cn("flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", className)} {...props}>
<ChartStyle id={chartId} config={config}/>
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (<style dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`)
.join("\n"),
}}/>);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef(({ active, payload, className, indicator = "dot", hideLabel = false, hideIndicator = false, label, labelFormatter, labelClassName, formatter, color, nameKey, labelKey, }, ref) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = !labelKey && typeof label === "string"
? config[label]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (<div ref={ref} className={cn("grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", className)}>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (<div key={item.dataKey} className={cn("flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center")}>
{formatter && item?.value !== undefined && item.name ? (formatter(item.value, item.name, item, index, item.payload)) : (<>
{itemConfig?.icon ? (<itemConfig.icon />) : (!hideIndicator && (<div className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})} style={{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
}}/>))}
<div className={cn("flex flex-1 justify-between leading-none", nestLabel ? "items-end" : "items-center")}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>)}
</div>
</>)}
</div>);
})}
</div>
</div>);
});
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (<div ref={ref} className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (<div key={item.value} className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}>
{itemConfig?.icon && !hideIcon ? (<itemConfig.icon />) : (<div className="h-2 w-2 shrink-0 rounded-[2px]" style={{
backgroundColor: item.color,
}}/>)}
{itemConfig?.label}
</div>);
})}
</div>);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config, payload, key) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload = "payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey = key;
if (key in payload &&
typeof payload[key] === "string") {
configLabelKey = payload[key];
}
else if (payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key] === "string") {
configLabelKey = payloadPayload[key];
}
return configLabelKey in config
? config[configLabelKey]
: config[key];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle, };

View File

@@ -0,0 +1,11 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (<CheckboxPrimitive.Root ref={ref} className={cn("peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", className)} {...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4"/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,6 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef(({ className, ...props }, ref) => (<CommandPrimitive ref={ref} className={cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className)} {...props}/>));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }) => {
return (<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>);
};
const CommandInput = React.forwardRef(({ className, ...props }, ref) => (<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50"/>
<CommandPrimitive.Input ref={ref} className={cn("flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}/>
</div>));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef(({ className, ...props }, ref) => (<CommandPrimitive.List ref={ref} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} {...props}/>));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef((props, ref) => (<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props}/>));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (<CommandPrimitive.Group ref={ref} className={cn("overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className)} {...props}/>));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props}/>));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (<CommandPrimitive.Item ref={ref} className={cn("relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", className)} {...props}/>));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }) => {
return (<span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
};
CommandShortcut.displayName = "CommandShortcut";
export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, };

View File

@@ -0,0 +1,18 @@
export const ConfirmationDialog = ({ isOpen, title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", confirmColor = "bg-blue-600 hover:bg-blue-700", onConfirm, onCancel, }) => {
if (!isOpen)
return null;
return (<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div className="bg-white p-6 rounded-md shadow-md w-[90%] max-w-md">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<p>{message}</p>
<div className="mt-6 flex justify-end space-x-4">
<button className="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300" onClick={onCancel}>
{cancelLabel}
</button>
<button className={`${confirmColor} text-white px-4 py-2 rounded`} onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>);
};

Some files were not shown because too many files have changed in this diff Show More