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:
@@ -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>);
|
||||
}
|
||||
47
apps/Frontend/src/components/analytics/new-patients.jsx
Normal file
47
apps/Frontend/src/components/analytics/new-patients.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
434
apps/Frontend/src/components/appointments/appointment-form.jsx
Normal file
434
apps/Frontend/src/components/appointments/appointment-form.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
258
apps/Frontend/src/components/chart/lab-management-tab.jsx
Normal file
258
apps/Frontend/src/components/chart/lab-management-tab.jsx
Normal 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>);
|
||||
}
|
||||
224
apps/Frontend/src/components/chart/prescription-tab.jsx
Normal file
224
apps/Frontend/src/components/chart/prescription-tab.jsx
Normal 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>);
|
||||
}
|
||||
155
apps/Frontend/src/components/chart/teeth-chart.jsx
Normal file
155
apps/Frontend/src/components/chart/teeth-chart.jsx
Normal 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>);
|
||||
}
|
||||
202
apps/Frontend/src/components/chart/treatment-plan-tab.jsx
Normal file
202
apps/Frontend/src/components/chart/treatment-plan-tab.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
271
apps/Frontend/src/components/claims/claim-edit-modal.jsx
Normal file
271
apps/Frontend/src/components/claims/claim-edit-modal.jsx
Normal 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>);
|
||||
}
|
||||
2343
apps/Frontend/src/components/claims/claim-form.jsx
Normal file
2343
apps/Frontend/src/components/claims/claim-form.jsx
Normal file
File diff suppressed because it is too large
Load Diff
272
apps/Frontend/src/components/claims/claim-view-modal.jsx
Normal file
272
apps/Frontend/src/components/claims/claim-view-modal.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
481
apps/Frontend/src/components/claims/claims-recent-table.jsx
Normal file
481
apps/Frontend/src/components/claims/claims-recent-table.jsx
Normal 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>);
|
||||
}
|
||||
58
apps/Frontend/src/components/claims/claims-ui.jsx
Normal file
58
apps/Frontend/src/components/claims/claims-ui.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
export function RemarksField({ value, onChange, debounceMs = 250, // tweak (150–300) 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>);
|
||||
}
|
||||
161
apps/Frontend/src/components/claims/tooth-ui.jsx
Normal file
161
apps/Frontend/src/components/claims/tooth-ui.jsx
Normal 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>);
|
||||
}
|
||||
111
apps/Frontend/src/components/cloud-storage/bread-crumb.jsx
Normal file
111
apps/Frontend/src/components/cloud-storage/bread-crumb.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
463
apps/Frontend/src/components/cloud-storage/files-section.jsx
Normal file
463
apps/Frontend/src/components/cloud-storage/files-section.jsx
Normal 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>);
|
||||
}
|
||||
119
apps/Frontend/src/components/cloud-storage/folder-panel.jsx
Normal file
119
apps/Frontend/src/components/cloud-storage/folder-panel.jsx
Normal 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>);
|
||||
}
|
||||
288
apps/Frontend/src/components/cloud-storage/folder-section.jsx
Normal file
288
apps/Frontend/src/components/cloud-storage/folder-section.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
293
apps/Frontend/src/components/cloud-storage/search-bar.jsx
Normal file
293
apps/Frontend/src/components/cloud-storage/search-bar.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>
|
||||
</>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
221
apps/Frontend/src/components/documents/file-preview-modal.jsx
Normal file
221
apps/Frontend/src/components/documents/file-preview-modal.jsx
Normal 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>);
|
||||
}
|
||||
195
apps/Frontend/src/components/file-upload/file-upload-zone.jsx
Normal file
195
apps/Frontend/src/components/file-upload/file-upload-zone.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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}/>
|
||||
</>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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}/>
|
||||
</>);
|
||||
}
|
||||
@@ -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}/>
|
||||
</>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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}/>
|
||||
</>);
|
||||
}
|
||||
@@ -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}/>
|
||||
</>);
|
||||
}
|
||||
60
apps/Frontend/src/components/insurance/credentials-modal.jsx
Normal file
60
apps/Frontend/src/components/insurance/credentials-modal.jsx
Normal 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>);
|
||||
}
|
||||
22
apps/Frontend/src/components/layout/app-layout.jsx
Normal file
22
apps/Frontend/src/components/layout/app-layout.jsx
Normal 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>);
|
||||
}
|
||||
1070
apps/Frontend/src/components/layout/chatbot.jsx
Normal file
1070
apps/Frontend/src/components/layout/chatbot.jsx
Normal file
File diff suppressed because it is too large
Load Diff
196
apps/Frontend/src/components/layout/notification-bell.jsx
Normal file
196
apps/Frontend/src/components/layout/notification-bell.jsx
Normal 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>);
|
||||
}
|
||||
302
apps/Frontend/src/components/layout/sidebar.jsx
Normal file
302
apps/Frontend/src/components/layout/sidebar.jsx
Normal 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>);
|
||||
}
|
||||
64
apps/Frontend/src/components/layout/top-app-bar.jsx
Normal file
64
apps/Frontend/src/components/layout/top-app-bar.jsx
Normal 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>);
|
||||
}
|
||||
200
apps/Frontend/src/components/patient-connection/dial-pad.jsx
Normal file
200
apps/Frontend/src/components/patient-connection/dial-pad.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
107
apps/Frontend/src/components/patients/add-patient-modal.jsx
Normal file
107
apps/Frontend/src/components/patients/add-patient-modal.jsx
Normal 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>);
|
||||
});
|
||||
@@ -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>);
|
||||
}
|
||||
181
apps/Frontend/src/components/patients/patient-search.jsx
Normal file
181
apps/Frontend/src/components/patients/patient-search.jsx
Normal 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>);
|
||||
}
|
||||
1190
apps/Frontend/src/components/patients/patient-table.jsx
Normal file
1190
apps/Frontend/src/components/patients/patient-table.jsx
Normal file
File diff suppressed because it is too large
Load Diff
811
apps/Frontend/src/components/payments/payment-edit-modal.jsx
Normal file
811
apps/Frontend/src/components/payments/payment-edit-modal.jsx
Normal 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>);
|
||||
}
|
||||
318
apps/Frontend/src/components/payments/payment-ocr-block.jsx
Normal file
318
apps/Frontend/src/components/payments/payment-ocr-block.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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;
|
||||
776
apps/Frontend/src/components/payments/payments-recent-table.jsx
Normal file
776
apps/Frontend/src/components/payments/payments-recent-table.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
393
apps/Frontend/src/components/reports/commission-section.jsx
Normal file
393
apps/Frontend/src/components/reports/commission-section.jsx
Normal 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>);
|
||||
}
|
||||
49
apps/Frontend/src/components/reports/export-button.jsx
Normal file
49
apps/Frontend/src/components/reports/export-button.jsx
Normal 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>);
|
||||
}
|
||||
37
apps/Frontend/src/components/reports/pagination-controls.jsx
Normal file
37
apps/Frontend/src/components/reports/pagination-controls.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
107
apps/Frontend/src/components/reports/report-config.jsx
Normal file
107
apps/Frontend/src/components/reports/report-config.jsx
Normal 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>);
|
||||
}
|
||||
94
apps/Frontend/src/components/reports/summary-cards.jsx
Normal file
94
apps/Frontend/src/components/reports/summary-cards.jsx
Normal 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>);
|
||||
}
|
||||
119
apps/Frontend/src/components/settings/InsuranceCredForm.jsx
Normal file
119
apps/Frontend/src/components/settings/InsuranceCredForm.jsx
Normal 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>);
|
||||
}
|
||||
1258
apps/Frontend/src/components/settings/ai-chat-settings-card.jsx
Normal file
1258
apps/Frontend/src/components/settings/ai-chat-settings-card.jsx
Normal file
File diff suppressed because it is too large
Load Diff
226
apps/Frontend/src/components/settings/ai-chat-templates-card.jsx
Normal file
226
apps/Frontend/src/components/settings/ai-chat-templates-card.jsx
Normal 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>);
|
||||
}
|
||||
235
apps/Frontend/src/components/settings/ai-settings-card.jsx
Normal file
235
apps/Frontend/src/components/settings/ai-settings-card.jsx
Normal 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>);
|
||||
}
|
||||
197
apps/Frontend/src/components/settings/insurance-contact-card.jsx
Normal file
197
apps/Frontend/src/components/settings/insurance-contact-card.jsx
Normal 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>);
|
||||
}
|
||||
155
apps/Frontend/src/components/settings/insuranceCredTable.jsx
Normal file
155
apps/Frontend/src/components/settings/insuranceCredTable.jsx
Normal 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>);
|
||||
}
|
||||
99
apps/Frontend/src/components/settings/npiProviderForm.jsx
Normal file
99
apps/Frontend/src/components/settings/npiProviderForm.jsx
Normal 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>);
|
||||
}
|
||||
162
apps/Frontend/src/components/settings/npiProviderTable.jsx
Normal file
162
apps/Frontend/src/components/settings/npiProviderTable.jsx
Normal 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>);
|
||||
}
|
||||
136
apps/Frontend/src/components/settings/office-contact-card.jsx
Normal file
136
apps/Frontend/src/components/settings/office-contact-card.jsx
Normal 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>);
|
||||
}
|
||||
231
apps/Frontend/src/components/settings/office-hours-card.jsx
Normal file
231
apps/Frontend/src/components/settings/office-hours-card.jsx
Normal 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>);
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
219
apps/Frontend/src/components/settings/program-bridge-table.jsx
Normal file
219
apps/Frontend/src/components/settings/program-bridge-table.jsx
Normal 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>);
|
||||
}
|
||||
123
apps/Frontend/src/components/settings/twilio-settings-card.jsx
Normal file
123
apps/Frontend/src/components/settings/twilio-settings-card.jsx
Normal 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>);
|
||||
}
|
||||
75
apps/Frontend/src/components/staffs/staff-form.jsx
Normal file
75
apps/Frontend/src/components/staffs/staff-form.jsx
Normal 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>);
|
||||
}
|
||||
161
apps/Frontend/src/components/staffs/staff-table.jsx
Normal file
161
apps/Frontend/src/components/staffs/staff-table.jsx
Normal 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>);
|
||||
}
|
||||
12
apps/Frontend/src/components/ui/LoadingScreen.jsx
Normal file
12
apps/Frontend/src/components/ui/LoadingScreen.jsx
Normal 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>);
|
||||
}
|
||||
19
apps/Frontend/src/components/ui/accordion.jsx
Normal file
19
apps/Frontend/src/components/ui/accordion.jsx
Normal 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 };
|
||||
28
apps/Frontend/src/components/ui/alert-dialog.jsx
Normal file
28
apps/Frontend/src/components/ui/alert-dialog.jsx
Normal 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, };
|
||||
21
apps/Frontend/src/components/ui/alert.jsx
Normal file
21
apps/Frontend/src/components/ui/alert.jsx
Normal 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 };
|
||||
3
apps/Frontend/src/components/ui/aspect-ratio.jsx
Normal file
3
apps/Frontend/src/components/ui/aspect-ratio.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
export { AspectRatio };
|
||||
11
apps/Frontend/src/components/ui/avatar.jsx
Normal file
11
apps/Frontend/src/components/ui/avatar.jsx
Normal 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 };
|
||||
22
apps/Frontend/src/components/ui/badge.jsx
Normal file
22
apps/Frontend/src/components/ui/badge.jsx
Normal 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 };
|
||||
27
apps/Frontend/src/components/ui/breadcrumb.jsx
Normal file
27
apps/Frontend/src/components/ui/breadcrumb.jsx
Normal 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, };
|
||||
35
apps/Frontend/src/components/ui/button.jsx
Normal file
35
apps/Frontend/src/components/ui/button.jsx
Normal 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 };
|
||||
44
apps/Frontend/src/components/ui/calendar.jsx
Normal file
44
apps/Frontend/src/components/ui/calendar.jsx
Normal 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>);
|
||||
}
|
||||
15
apps/Frontend/src/components/ui/card.jsx
Normal file
15
apps/Frontend/src/components/ui/card.jsx
Normal 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 };
|
||||
109
apps/Frontend/src/components/ui/carousel.jsx
Normal file
109
apps/Frontend/src/components/ui/carousel.jsx
Normal 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, };
|
||||
164
apps/Frontend/src/components/ui/chart.jsx
Normal file
164
apps/Frontend/src/components/ui/chart.jsx
Normal 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, };
|
||||
11
apps/Frontend/src/components/ui/checkbox.jsx
Normal file
11
apps/Frontend/src/components/ui/checkbox.jsx
Normal 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 };
|
||||
6
apps/Frontend/src/components/ui/collapsible.jsx
Normal file
6
apps/Frontend/src/components/ui/collapsible.jsx
Normal 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 };
|
||||
36
apps/Frontend/src/components/ui/command.jsx
Normal file
36
apps/Frontend/src/components/ui/command.jsx
Normal 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, };
|
||||
18
apps/Frontend/src/components/ui/confirmationDialog.jsx
Normal file
18
apps/Frontend/src/components/ui/confirmationDialog.jsx
Normal 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
Reference in New Issue
Block a user